diff --git a/plugins/openchoreo-backend/src/router.ts b/plugins/openchoreo-backend/src/router.ts index ca9e25407..eebd0d202 100644 --- a/plugins/openchoreo-backend/src/router.ts +++ b/plugins/openchoreo-backend/src/router.ts @@ -1141,6 +1141,29 @@ export async function createRouter({ ); }); + router.get('/component-releases', async (req, res) => { + const { componentName, namespaceName } = req.query; + + if (!componentName || !namespaceName) { + throw new InputError( + 'componentName and namespaceName are required query parameters', + ); + } + + const userToken = getUserTokenFromRequest(req); + + const rawReleases = await environmentInfoService.listComponentReleases( + { + componentName: componentName as string, + namespaceName: namespaceName as string, + }, + userToken, + ); + const items = (rawReleases as any)?.items ?? []; + res.json({ success: true, data: { items } }); + }); + + router.put('/update-release-binding', requireAuth, async (req, res) => { const { componentName, diff --git a/plugins/openchoreo-backend/src/services/EnvironmentService/EnvironmentInfoService.ts b/plugins/openchoreo-backend/src/services/EnvironmentService/EnvironmentInfoService.ts index b41a07d5c..d6c1f0c24 100644 --- a/plugins/openchoreo-backend/src/services/EnvironmentService/EnvironmentInfoService.ts +++ b/plugins/openchoreo-backend/src/services/EnvironmentService/EnvironmentInfoService.ts @@ -2028,6 +2028,60 @@ export class EnvironmentInfoService implements EnvironmentService { } } + /** + * Lists component releases for a specific component within a namespace. + * + * @param request.componentName - Name of the component to filter releases by + * @param request.namespaceName - Name of the namespace + * @returns Paginated list of component releases (frozen workload snapshots) + */ + async listComponentReleases( + request: { + componentName: string; + namespaceName: string; + }, + token?: string, + ) { + const startTime = Date.now(); + this.logger.debug( + `Listing component releases for ${request.componentName}`, + ); + + try { + const client = createOpenChoreoApiClient({ + baseUrl: this.baseUrl, + token, + logger: this.logger, + }); + + const { data, error, response } = await client.GET( + '/api/v1/namespaces/{namespaceName}/componentreleases', + { + params: { + path: { namespaceName: request.namespaceName }, + query: { component: request.componentName }, + }, + }, + ); + + assertApiResponse({ data, error, response }, 'list component releases'); + + const totalTime = Date.now() - startTime; + this.logger.debug( + `Component releases listed for ${request.componentName}: Total: ${totalTime}ms`, + ); + + return data; + } catch (error: unknown) { + const totalTime = Date.now() - startTime; + this.logger.error( + `Error listing component releases for ${request.componentName} (${totalTime}ms):`, + error as Error, + ); + throw error; + } + } + /** * Fetches the (Cluster)ResourceType referenced by `resource.spec.type` * and returns its `spec.retainPolicy`, or `undefined` if neither the diff --git a/plugins/openchoreo-react/src/components/DetailPageLayout/DetailPageLayout.test.tsx b/plugins/openchoreo-react/src/components/DetailPageLayout/DetailPageLayout.test.tsx new file mode 100644 index 000000000..2693cffbf --- /dev/null +++ b/plugins/openchoreo-react/src/components/DetailPageLayout/DetailPageLayout.test.tsx @@ -0,0 +1,43 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { DetailPageLayout } from './DetailPageLayout'; + +describe('DetailPageLayout', () => { + it('calls onBack when Escape is pressed', () => { + const onBack = jest.fn(); + render( + +
body
+
, + ); + + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onBack).toHaveBeenCalledTimes(1); + }); + + it('does not call onBack when Escape is pressed while an input is focused', () => { + const onBack = jest.fn(); + render( + + + , + ); + + const input = screen.getByTestId('field') as HTMLInputElement; + input.focus(); + expect(document.activeElement).toBe(input); + + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onBack).not.toHaveBeenCalled(); + }); + + it('renders the Esc shortcut chip in the header', () => { + render( + +
+ , + ); + expect(screen.getByLabelText(/press escape to go back/i)).toHaveTextContent( + 'Esc', + ); + }); +}); diff --git a/plugins/openchoreo-react/src/components/DetailPageLayout/DetailPageLayout.tsx b/plugins/openchoreo-react/src/components/DetailPageLayout/DetailPageLayout.tsx index 3dc0d127e..5d9044531 100644 --- a/plugins/openchoreo-react/src/components/DetailPageLayout/DetailPageLayout.tsx +++ b/plugins/openchoreo-react/src/components/DetailPageLayout/DetailPageLayout.tsx @@ -1,16 +1,18 @@ import { Box, IconButton, Typography } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import ArrowBackIcon from '@material-ui/icons/ArrowBack'; -import type { ReactNode } from 'react'; +import { useEffect, type ReactNode } from 'react'; const useStyles = makeStyles(theme => ({ container: { display: 'flex', flexDirection: 'column', - // Constrain height to prevent page-level scrolling - // Accounts for: Backstage header (~72px) + tabs (~69px) + Content padding (48px) + buffer - height: 'calc(100vh - 240px)', - maxHeight: 'calc(100vh - 240px)', + // Match the deploy list view's height contract so the inner page never + // pushes Backstage's into external scroll. 200px accounts for the + // entity header (~104px) + tab strip (~50px) + 24px top + 24px + // bottom + a small bottom buffer. `min-height` covers tiny viewports. + height: 'calc(100vh - 200px)', + minHeight: 480, overflow: 'hidden', }, header: { @@ -18,18 +20,41 @@ const useStyles = makeStyles(theme => ({ display: 'flex', alignItems: 'center', justifyContent: 'space-between', - padding: theme.spacing(2, 0), + padding: theme.spacing(2), borderBottom: `1px solid ${theme.palette.divider}`, flexShrink: 0, }, + backControl: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 2, + marginRight: theme.spacing(1), + }, + kbdChip: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + minWidth: 22, + height: 14, + padding: '0 4px', + border: `1px solid ${theme.palette.divider}`, + borderRadius: 3, + backgroundColor: theme.palette.background.default, + color: theme.palette.text.secondary, + fontFamily: + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace', + fontSize: 9, + lineHeight: 1, + fontWeight: 500, + letterSpacing: '0.04em', + textTransform: 'uppercase', + }, headerLeft: { display: 'flex', alignItems: 'center', gap: theme.spacing(1), }, - backButton: { - marginRight: theme.spacing(1), - }, titleContainer: { display: 'flex', flexDirection: 'column', @@ -51,12 +76,12 @@ const useStyles = makeStyles(theme => ({ content: { flex: 1, overflowY: 'auto', - paddingTop: theme.spacing(2), + padding: theme.spacing(2), }, })); export interface DetailPageLayoutProps { - title: string; + title: ReactNode; subtitle?: ReactNode; onBack: () => void; actions?: ReactNode; @@ -76,18 +101,45 @@ export const DetailPageLayout = ({ }: DetailPageLayoutProps) => { const classes = useStyles(); + // Esc closes the page via the same path as the back arrow, so the + // unsaved-changes dialog (when present) still fires. Skip when the user + // is typing in a field — Esc-while-typing is a common reflex. + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return; + const target = document.activeElement as HTMLElement | null; + if (!target) { + onBack(); + return; + } + const tag = target.tagName; + const isEditable = + tag === 'INPUT' || + tag === 'TEXTAREA' || + tag === 'SELECT' || + target.isContentEditable; + if (isEditable) return; + onBack(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onBack]); + return ( - - - + + + + + + Esc + + {title} diff --git a/plugins/openchoreo-react/src/components/EnvVarStatusBadge/EnvVarStatusBadge.tsx b/plugins/openchoreo-react/src/components/EnvVarStatusBadge/EnvVarStatusBadge.tsx index 3291f791a..7e844937d 100644 --- a/plugins/openchoreo-react/src/components/EnvVarStatusBadge/EnvVarStatusBadge.tsx +++ b/plugins/openchoreo-react/src/components/EnvVarStatusBadge/EnvVarStatusBadge.tsx @@ -30,6 +30,10 @@ const useStyles = makeStyles((theme: Theme) => ({ backgroundColor: theme.palette.success.light, color: theme.palette.success.contrastText, }, + extra: { + backgroundColor: theme.palette.warning.light, + color: theme.palette.warning.contrastText, + }, })); const statusConfig: Record< @@ -37,7 +41,7 @@ const statusConfig: Record< { label: string; tooltip: string; - className: 'inherited' | 'overridden' | 'new'; + className: 'inherited' | 'overridden' | 'new' | 'extra'; } > = { inherited: { @@ -55,6 +59,11 @@ const statusConfig: Record< tooltip: 'New environment variable', className: 'new', }, + extra: { + label: 'Extra', + tooltip: 'Not in current workload', + className: 'extra', + }, }; /** diff --git a/plugins/openchoreo-react/src/components/FileVarStatusBadge/FileVarStatusBadge.tsx b/plugins/openchoreo-react/src/components/FileVarStatusBadge/FileVarStatusBadge.tsx index 9fd884ed5..47717d550 100644 --- a/plugins/openchoreo-react/src/components/FileVarStatusBadge/FileVarStatusBadge.tsx +++ b/plugins/openchoreo-react/src/components/FileVarStatusBadge/FileVarStatusBadge.tsx @@ -33,6 +33,10 @@ const useStyles = makeStyles((theme: Theme) => ({ backgroundColor: theme.palette.success.light, color: theme.palette.success.contrastText, }, + extra: { + backgroundColor: theme.palette.warning.light, + color: theme.palette.warning.contrastText, + }, })); const statusConfig: Record< @@ -40,7 +44,7 @@ const statusConfig: Record< { label: string; tooltip: string; - className: 'inherited' | 'overridden' | 'new'; + className: 'inherited' | 'overridden' | 'new' | 'extra'; } > = { inherited: { @@ -58,6 +62,11 @@ const statusConfig: Record< tooltip: 'New file mount', className: 'new', }, + extra: { + label: 'Extra', + tooltip: 'Not in current workload', + className: 'extra', + }, }; /** diff --git a/plugins/openchoreo-react/src/components/GroupedSection/GroupedSection.tsx b/plugins/openchoreo-react/src/components/GroupedSection/GroupedSection.tsx index 612071935..7a9001cb2 100644 --- a/plugins/openchoreo-react/src/components/GroupedSection/GroupedSection.tsx +++ b/plugins/openchoreo-react/src/components/GroupedSection/GroupedSection.tsx @@ -1,5 +1,13 @@ import { useState, type FC, type ReactNode } from 'react'; -import { Box, Typography, IconButton, Collapse, Chip } from '@material-ui/core'; +import { + Box, + Typography, + IconButton, + Collapse, + Chip, + Tooltip, +} from '@material-ui/core'; +import HelpOutlineIcon from '@material-ui/icons/HelpOutline'; import { makeStyles, Theme } from '@material-ui/core/styles'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import ExpandLessIcon from '@material-ui/icons/ExpandLess'; @@ -9,6 +17,8 @@ export type GroupedSectionStatus = 'overridden' | 'new' | 'inherited'; export interface GroupedSectionProps { /** Section title (falls back to default based on status if not provided) */ title?: string; + /** Optional tooltip explaining the section; renders a help icon next to the title. */ + titleTooltip?: string; /** Number of items in section */ count: number; /** Status type for color accent */ @@ -77,6 +87,10 @@ const useStyles = makeStyles((theme: Theme) => ({ fontSize: '1rem', color: theme.palette.text.disabled, }, + helpIcon: { + fontSize: '0.95rem', + color: theme.palette.text.secondary, + }, content: { paddingLeft: theme.spacing(1.5), paddingTop: theme.spacing(1), @@ -95,6 +109,7 @@ const statusTitles: Record = { */ export const GroupedSection: FC = ({ title, + titleTooltip, count, status, defaultExpanded = true, @@ -136,6 +151,14 @@ export const GroupedSection: FC = ({ {displayTitle} + {titleTooltip && ( + + e.stopPropagation()} + /> + + )} ({ borderRadius: 6, border: `1px dashed ${theme.palette.grey[300]}`, }, + rowHeader: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + marginBottom: theme.spacing(0.5), + }, + newChip: { + height: 18, + fontSize: 10, + letterSpacing: '0.04em', + }, addButton: { marginTop: theme.spacing(1), }, @@ -36,7 +47,13 @@ export interface OverrideEnvVarListProps { envVars: EnvVar[]; /** Base workload environment variables (for comparison) */ baseEnvVars: EnvVar[]; - /** Environment name for display in section titles */ + /** + * Override env vars as initially loaded from the binding. Used to + * distinguish entries that were already persisted (`extra`) from + * ones the user just added in this session (`new`). + */ + initialEnvVars?: EnvVar[]; + /** Environment name for display in section titles (currently unused). */ environmentName?: string; /** Available secrets for reference selection */ secretOptions: SecretOption[]; @@ -76,7 +93,8 @@ export const OverrideEnvVarList: FC = ({ containerName, envVars, baseEnvVars, - environmentName, + initialEnvVars, + // environmentName intentionally unused — section title is no longer per-env. secretOptions, envModes, disabled, @@ -91,8 +109,8 @@ export const OverrideEnvVarList: FC = ({ // Merge base and override env vars with status metadata const mergedEnvVars = useMemo( - () => mergeEnvVarsWithStatus(baseEnvVars, envVars), - [baseEnvVars, envVars], + () => mergeEnvVarsWithStatus(baseEnvVars, envVars, initialEnvVars), + [baseEnvVars, envVars, initialEnvVars], ); // Group items by status @@ -162,6 +180,16 @@ export const OverrideEnvVarList: FC = ({ key={`${item.status}-${item.envVar.key}-${displayIndex}`} className={classes.envVarRowWrapper} > + {item.status === 'new' && ( + + + + )} = ({ return ( - {/* Environment-specific section */} - {grouped.new.length > 0 && ( + {/* Not in current workload — overrides whose key isn't in the bound release's workload. + Includes both stale entries already on the binding ('extra') and ones the user + just added in this form session ('new'). 'new' rows show a NEW chip. */} + {grouped.extra.length + grouped.new.length > 0 && ( - {grouped.new.map((item, index) => + {[...grouped.extra, ...grouped.new].map((item, index) => renderEditableRow(item as EnvVarWithStatus, index), )} diff --git a/plugins/openchoreo-react/src/components/OverrideFileVarList/OverrideFileVarList.tsx b/plugins/openchoreo-react/src/components/OverrideFileVarList/OverrideFileVarList.tsx index 1b2e00c8d..18980893f 100644 --- a/plugins/openchoreo-react/src/components/OverrideFileVarList/OverrideFileVarList.tsx +++ b/plugins/openchoreo-react/src/components/OverrideFileVarList/OverrideFileVarList.tsx @@ -1,5 +1,5 @@ import { useMemo, type FC } from 'react'; -import { Box, Button } from '@material-ui/core'; +import { Box, Button, Chip } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import AddIcon from '@material-ui/icons/Add'; import type { FileVar } from '@openchoreo/backstage-plugin-common'; @@ -24,6 +24,17 @@ const useStyles = makeStyles(theme => ({ borderRadius: 6, border: `1px dashed ${theme.palette.grey[300]}`, }, + rowHeader: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + marginBottom: theme.spacing(0.5), + }, + newChip: { + height: 18, + fontSize: 10, + letterSpacing: '0.04em', + }, addButton: { marginTop: theme.spacing(1), }, @@ -36,7 +47,13 @@ export interface OverrideFileVarListProps { fileVars: FileVar[]; /** Base workload file mounts (for comparison) */ baseFileVars: FileVar[]; - /** Environment name for display in section titles */ + /** + * Override file mounts as initially loaded from the binding. Used to + * distinguish entries that were already persisted (`extra`) from + * ones the user just added in this session (`new`). + */ + initialFileVars?: FileVar[]; + /** Environment name for display in section titles (currently unused). */ environmentName?: string; /** Available secrets for reference selection */ secretOptions: SecretOption[]; @@ -76,7 +93,8 @@ export const OverrideFileVarList: FC = ({ containerName, fileVars, baseFileVars, - environmentName, + initialFileVars, + // environmentName intentionally unused — section title is no longer per-env. secretOptions, fileModes, disabled, @@ -91,8 +109,8 @@ export const OverrideFileVarList: FC = ({ // Merge base and override file vars with status metadata const mergedFileVars = useMemo( - () => mergeFileVarsWithStatus(baseFileVars, fileVars), - [baseFileVars, fileVars], + () => mergeFileVarsWithStatus(baseFileVars, fileVars, initialFileVars), + [baseFileVars, fileVars, initialFileVars], ); // Group items by status @@ -165,6 +183,16 @@ export const OverrideFileVarList: FC = ({ key={`${item.status}-${item.fileVar.key}-${displayIndex}`} className={classes.fileVarRowWrapper} > + {item.status === 'new' && ( + + + + )} = ({ return ( - {/* Environment-specific section */} - {grouped.new.length > 0 && ( + {/* Not in current workload — overrides whose key isn't in the bound release's workload. + Includes both stale entries already on the binding ('extra') and ones the user + just added in this form session ('new'). 'new' rows show a NEW chip. */} + {grouped.extra.length + grouped.new.length > 0 && ( - {grouped.new.map((item, index) => + {[...grouped.extra, ...grouped.new].map((item, index) => renderEditableRow(item as FileVarWithStatus, index), )} diff --git a/plugins/openchoreo-react/src/components/PipelineFlowVisualization/dag/pipelineLayoutUtils.ts b/plugins/openchoreo-react/src/components/PipelineFlowVisualization/dag/pipelineLayoutUtils.ts index d5162cba0..de057f672 100644 --- a/plugins/openchoreo-react/src/components/PipelineFlowVisualization/dag/pipelineLayoutUtils.ts +++ b/plugins/openchoreo-react/src/components/PipelineFlowVisualization/dag/pipelineLayoutUtils.ts @@ -21,8 +21,8 @@ export const SETUP_NODE_HEIGHT = ENV_NODE_HEIGHT; // rendering inside the detail panel. export const MINI_ENV_NODE_WIDTH = 240; export const MINI_ENV_NODE_HEIGHT = 140; -export const MINI_SETUP_NODE_WIDTH = 240; -export const MINI_SETUP_NODE_HEIGHT = 140; +export const MINI_SETUP_NODE_WIDTH = 180; +export const MINI_SETUP_NODE_HEIGHT = 96; /** Minimal env shape used by buildEnvPipelineNodes */ export interface EnvPipelineInput { diff --git a/plugins/openchoreo-react/src/utils/envVarUtils.test.ts b/plugins/openchoreo-react/src/utils/envVarUtils.test.ts index 2ba863418..8e2c7de27 100644 --- a/plugins/openchoreo-react/src/utils/envVarUtils.test.ts +++ b/plugins/openchoreo-react/src/utils/envVarUtils.test.ts @@ -30,7 +30,7 @@ describe('mergeEnvVarsWithStatus', () => { expect(result[0].actualIndex).toBe(0); }); - it('marks new override vars', () => { + it('marks not-in-base vars as new when no initial snapshot is provided (legacy)', () => { const base: EnvVar[] = []; const override: EnvVar[] = [{ key: 'NEW_VAR', value: 'hello' }]; const result = mergeEnvVarsWithStatus(base, override); @@ -41,6 +41,39 @@ describe('mergeEnvVarsWithStatus', () => { expect(result[0].actualIndex).toBe(0); }); + it('marks vars present in initial-but-not-in-base as extra', () => { + const base: EnvVar[] = []; + const initial: EnvVar[] = [{ key: 'STALE', value: 'old' }]; + const override: EnvVar[] = [{ key: 'STALE', value: 'old' }]; + const result = mergeEnvVarsWithStatus(base, override, initial); + + expect(result).toHaveLength(1); + expect(result[0].status).toBe('extra'); + expect(result[0].envVar.key).toBe('STALE'); + }); + + it('marks vars absent from initial as new when initial is provided', () => { + const base: EnvVar[] = []; + const initial: EnvVar[] = []; + const override: EnvVar[] = [{ key: 'JUST_ADDED', value: '1' }]; + const result = mergeEnvVarsWithStatus(base, override, initial); + + expect(result).toHaveLength(1); + expect(result[0].status).toBe('new'); + }); + + it('mixes extra and new in the same merge when initial is partial', () => { + const base: EnvVar[] = []; + const initial: EnvVar[] = [{ key: 'STALE', value: 'old' }]; + const override: EnvVar[] = [ + { key: 'STALE', value: 'old' }, + { key: 'JUST_ADDED', value: '1' }, + ]; + const result = mergeEnvVarsWithStatus(base, override, initial); + + expect(result.map(r => r.status)).toEqual(['extra', 'new']); + }); + it('handles mixed inherited, overridden, and new', () => { const base: EnvVar[] = [ { key: 'KEEP', value: 'a' }, diff --git a/plugins/openchoreo-react/src/utils/envVarUtils.ts b/plugins/openchoreo-react/src/utils/envVarUtils.ts index 573d699ac..4269fdc08 100644 --- a/plugins/openchoreo-react/src/utils/envVarUtils.ts +++ b/plugins/openchoreo-react/src/utils/envVarUtils.ts @@ -7,9 +7,11 @@ import type { * Status of an environment variable in the override context. * - 'inherited': From base workload, not overridden * - 'overridden': Has a base value that is being overridden - * - 'new': New env var added in override, not in base workload + * - 'extra': In the persisted overrides but not in the base workload (e.g. a + * stale entry left over after the bound release changed) + * - 'new': Added by the user in the current form session and not in base */ -export type EnvVarStatus = 'inherited' | 'overridden' | 'new'; +export type EnvVarStatus = 'inherited' | 'overridden' | 'extra' | 'new'; /** * Environment variable with its override status metadata. @@ -27,18 +29,28 @@ export interface EnvVarWithStatus { /** * Merges base workload env vars with override env vars into a unified list. - * Returns status for each: inherited, overridden, or new. + * Returns status for each: inherited, overridden, extra, or new. + * + * When `initialOverrideEnvVars` is provided, an override key not in `base` is + * tagged `'extra'` if it was already in `initial` (i.e. loaded from the + * binding) and `'new'` otherwise (i.e. added in the current form session). + * When omitted, everything not-in-base falls back to `'new'` for backwards + * compatibility with legacy callers. * * @param baseEnvVars - Environment variables from the base workload * @param overrideEnvVars - Environment variables from the override form - * @returns Unified list with status metadata for each env var + * @param initialOverrideEnvVars - Snapshot of overrides as initially loaded */ export function mergeEnvVarsWithStatus( baseEnvVars: EnvVar[], overrideEnvVars: EnvVar[], + initialOverrideEnvVars?: EnvVar[], ): EnvVarWithStatus[] { const result: EnvVarWithStatus[] = []; const baseMap = new Map(baseEnvVars.map(e => [e.key, e])); + const initialKeys = initialOverrideEnvVars + ? new Set(initialOverrideEnvVars.map(e => e.key)) + : undefined; // Create a map of override keys to their actual indices in the array const overrideIndexMap = new Map( @@ -63,13 +75,17 @@ export function mergeEnvVarsWithStatus( } } - // Add new override env vars (not in base) + // Add override env vars not present in base. + // Without an `initial` snapshot, mark every such entry 'new' (legacy). + // With it, persisted-but-not-in-base entries become 'extra'. for (let i = 0; i < overrideEnvVars.length; i++) { const overrideEnv = overrideEnvVars[i]; if (!baseMap.has(overrideEnv.key)) { + const status: EnvVarStatus = + initialKeys && initialKeys.has(overrideEnv.key) ? 'extra' : 'new'; result.push({ envVar: overrideEnv, - status: 'new', + status, actualIndex: i, }); } diff --git a/plugins/openchoreo-react/src/utils/fileVarUtils.test.ts b/plugins/openchoreo-react/src/utils/fileVarUtils.test.ts index 90fce2014..6ee9216c7 100644 --- a/plugins/openchoreo-react/src/utils/fileVarUtils.test.ts +++ b/plugins/openchoreo-react/src/utils/fileVarUtils.test.ts @@ -34,7 +34,7 @@ describe('mergeFileVarsWithStatus', () => { expect(result[0].actualIndex).toBe(0); }); - it('marks new files in override', () => { + it('marks not-in-base files as new when no initial snapshot is provided (legacy)', () => { const result = mergeFileVarsWithStatus( [], [{ key: 'new.txt', mountPath: '/tmp', value: 'data' }], @@ -45,6 +45,30 @@ describe('mergeFileVarsWithStatus', () => { expect(result[0].actualIndex).toBe(0); }); + it('marks files present in initial-but-not-in-base as extra', () => { + const initial: FileVar[] = [ + { key: 'stale.yaml', mountPath: '/etc', value: 'old' }, + ]; + const override: FileVar[] = [ + { key: 'stale.yaml', mountPath: '/etc', value: 'old' }, + ]; + const result = mergeFileVarsWithStatus([], override, initial); + + expect(result).toHaveLength(1); + expect(result[0].status).toBe('extra'); + }); + + it('marks files absent from initial as new when initial is provided', () => { + const result = mergeFileVarsWithStatus( + [], + [{ key: 'fresh.txt', mountPath: '/x', value: 'y' }], + [], + ); + + expect(result).toHaveLength(1); + expect(result[0].status).toBe('new'); + }); + it('handles mixed statuses', () => { const base: FileVar[] = [ { key: 'keep.txt', mountPath: '/a', value: 'keep' }, diff --git a/plugins/openchoreo-react/src/utils/fileVarUtils.ts b/plugins/openchoreo-react/src/utils/fileVarUtils.ts index 1ade2f6c6..309686371 100644 --- a/plugins/openchoreo-react/src/utils/fileVarUtils.ts +++ b/plugins/openchoreo-react/src/utils/fileVarUtils.ts @@ -7,9 +7,10 @@ import type { * Status of a file mount in the override context. * - 'inherited': From base workload, not overridden * - 'overridden': Has a base value that is being overridden - * - 'new': New file mount added in override, not in base workload + * - 'extra': In the persisted overrides but not in the base workload + * - 'new': Added by the user in the current form session and not in base */ -export type FileVarStatus = 'inherited' | 'overridden' | 'new'; +export type FileVarStatus = 'inherited' | 'overridden' | 'extra' | 'new'; /** * File mount with its override status metadata. @@ -27,20 +28,24 @@ export interface FileVarWithStatus { /** * Merges base workload file mounts with override file mounts into a unified list. - * Returns status for each: inherited, overridden, or new. + * Returns status for each: inherited, overridden, extra, or new. * - * File mounts are matched by their key (file name). + * When `initialOverrideFileVars` is provided, an override key not in `base` is + * tagged `'extra'` if it was already in `initial` and `'new'` otherwise. + * When omitted, falls back to legacy behavior (everything not-in-base → `'new'`). * - * @param baseFileVars - File mounts from the base workload - * @param overrideFileVars - File mounts from the override form - * @returns Unified list with status metadata for each file mount + * File mounts are matched by their key (file name). */ export function mergeFileVarsWithStatus( baseFileVars: FileVar[], overrideFileVars: FileVar[], + initialOverrideFileVars?: FileVar[], ): FileVarWithStatus[] { const result: FileVarWithStatus[] = []; const baseMap = new Map(baseFileVars.map(f => [f.key, f])); + const initialKeys = initialOverrideFileVars + ? new Set(initialOverrideFileVars.map(f => f.key)) + : undefined; // Create a map of override keys to their actual indices in the array const overrideIndexMap = new Map( @@ -65,13 +70,15 @@ export function mergeFileVarsWithStatus( } } - // Add new override file vars (not in base) + // Add override file vars not present in base ('extra' if loaded, else 'new'). for (let i = 0; i < overrideFileVars.length; i++) { const overrideFile = overrideFileVars[i]; if (!baseMap.has(overrideFile.key)) { + const status: FileVarStatus = + initialKeys && initialKeys.has(overrideFile.key) ? 'extra' : 'new'; result.push({ fileVar: overrideFile, - status: 'new', + status, actualIndex: i, }); } diff --git a/plugins/openchoreo-react/src/utils/overrideGroupUtils.test.ts b/plugins/openchoreo-react/src/utils/overrideGroupUtils.test.ts index 5b9f047e9..cc0e1edef 100644 --- a/plugins/openchoreo-react/src/utils/overrideGroupUtils.test.ts +++ b/plugins/openchoreo-react/src/utils/overrideGroupUtils.test.ts @@ -1,4 +1,4 @@ -import type { EnvVarWithStatus } from './envVarUtils'; +import type { EnvVarStatus, EnvVarWithStatus } from './envVarUtils'; import { groupByStatus, getStatusCounts, @@ -6,16 +6,19 @@ import { getTotalCount, } from './overrideGroupUtils'; -function makeItem( - status: 'inherited' | 'overridden' | 'new', -): EnvVarWithStatus { +function makeItem(status: EnvVarStatus): EnvVarWithStatus { return { envVar: { key: `key-${status}` }, status }; } describe('groupByStatus', () => { it('returns empty groups for empty array', () => { const result = groupByStatus([]); - expect(result).toEqual({ overridden: [], new: [], inherited: [] }); + expect(result).toEqual({ + overridden: [], + new: [], + extra: [], + inherited: [], + }); }); it('groups items by status', () => { @@ -23,6 +26,7 @@ describe('groupByStatus', () => { makeItem('inherited'), makeItem('overridden'), makeItem('new'), + makeItem('extra'), makeItem('inherited'), ]; const result = groupByStatus(items); @@ -30,6 +34,7 @@ describe('groupByStatus', () => { expect(result.inherited).toHaveLength(2); expect(result.overridden).toHaveLength(1); expect(result.new).toHaveLength(1); + expect(result.extra).toHaveLength(1); }); it('handles all same status', () => { @@ -39,6 +44,7 @@ describe('groupByStatus', () => { expect(result.new).toHaveLength(2); expect(result.inherited).toHaveLength(0); expect(result.overridden).toHaveLength(0); + expect(result.extra).toHaveLength(0); }); }); @@ -47,6 +53,7 @@ describe('getStatusCounts', () => { expect(getStatusCounts([])).toEqual({ overridden: 0, new: 0, + extra: 0, inherited: 0, }); }); @@ -57,33 +64,50 @@ describe('getStatusCounts', () => { makeItem('overridden'), makeItem('new'), makeItem('new'), + makeItem('extra'), ]; expect(getStatusCounts(items)).toEqual({ inherited: 1, overridden: 1, new: 2, + extra: 1, }); }); }); describe('hasAnyItems', () => { it('returns false when all zeros', () => { - expect(hasAnyItems({ overridden: 0, new: 0, inherited: 0 })).toBe(false); + expect(hasAnyItems({ overridden: 0, new: 0, extra: 0, inherited: 0 })).toBe( + false, + ); }); it('returns true when any category has items', () => { - expect(hasAnyItems({ overridden: 0, new: 1, inherited: 0 })).toBe(true); - expect(hasAnyItems({ overridden: 1, new: 0, inherited: 0 })).toBe(true); - expect(hasAnyItems({ overridden: 0, new: 0, inherited: 3 })).toBe(true); + expect(hasAnyItems({ overridden: 0, new: 1, extra: 0, inherited: 0 })).toBe( + true, + ); + expect(hasAnyItems({ overridden: 1, new: 0, extra: 0, inherited: 0 })).toBe( + true, + ); + expect(hasAnyItems({ overridden: 0, new: 0, extra: 2, inherited: 0 })).toBe( + true, + ); + expect(hasAnyItems({ overridden: 0, new: 0, extra: 0, inherited: 3 })).toBe( + true, + ); }); }); describe('getTotalCount', () => { it('sums all categories', () => { - expect(getTotalCount({ overridden: 2, new: 3, inherited: 5 })).toBe(10); + expect( + getTotalCount({ overridden: 2, new: 3, extra: 1, inherited: 5 }), + ).toBe(11); }); it('returns 0 for all zeros', () => { - expect(getTotalCount({ overridden: 0, new: 0, inherited: 0 })).toBe(0); + expect( + getTotalCount({ overridden: 0, new: 0, extra: 0, inherited: 0 }), + ).toBe(0); }); }); diff --git a/plugins/openchoreo-react/src/utils/overrideGroupUtils.ts b/plugins/openchoreo-react/src/utils/overrideGroupUtils.ts index 1c3477211..4b8581116 100644 --- a/plugins/openchoreo-react/src/utils/overrideGroupUtils.ts +++ b/plugins/openchoreo-react/src/utils/overrideGroupUtils.ts @@ -7,6 +7,7 @@ import type { FileVarStatus, FileVarWithStatus } from './fileVarUtils'; export interface StatusCounts { overridden: number; new: number; + extra: number; inherited: number; } @@ -16,6 +17,7 @@ export interface StatusCounts { export interface GroupedItems { overridden: T[]; new: T[]; + extra: T[]; inherited: T[]; } @@ -23,7 +25,7 @@ type ItemWithStatus = EnvVarWithStatus | FileVarWithStatus; type StatusType = EnvVarStatus | FileVarStatus; /** - * Groups items by their status (overridden, new, inherited). + * Groups items by their status (overridden, new, extra, inherited). * Maintains the original actualIndex for each item. * * @param items - Array of items with status metadata @@ -35,6 +37,7 @@ export function groupByStatus( const result: GroupedItems = { overridden: [], new: [], + extra: [], inherited: [], }; @@ -44,6 +47,8 @@ export function groupByStatus( result.overridden.push(item); } else if (status === 'new') { result.new.push(item); + } else if (status === 'extra') { + result.extra.push(item); } else { result.inherited.push(item); } @@ -64,6 +69,7 @@ export function getStatusCounts( const counts: StatusCounts = { overridden: 0, new: 0, + extra: 0, inherited: 0, }; @@ -73,6 +79,8 @@ export function getStatusCounts( counts.overridden++; } else if (status === 'new') { counts.new++; + } else if (status === 'extra') { + counts.extra++; } else { counts.inherited++; } @@ -88,7 +96,12 @@ export function getStatusCounts( * @returns True if any category has items */ export function hasAnyItems(counts: StatusCounts): boolean { - return counts.overridden > 0 || counts.new > 0 || counts.inherited > 0; + return ( + counts.overridden > 0 || + counts.new > 0 || + counts.extra > 0 || + counts.inherited > 0 + ); } /** @@ -98,5 +111,5 @@ export function hasAnyItems(counts: StatusCounts): boolean { * @returns Total count */ export function getTotalCount(counts: StatusCounts): number { - return counts.overridden + counts.new + counts.inherited; + return counts.overridden + counts.new + counts.extra + counts.inherited; } diff --git a/plugins/openchoreo/src/api/OpenChoreoClient.ts b/plugins/openchoreo/src/api/OpenChoreoClient.ts index 06424d2f0..a1ef23395 100644 --- a/plugins/openchoreo/src/api/OpenChoreoClient.ts +++ b/plugins/openchoreo/src/api/OpenChoreoClient.ts @@ -10,6 +10,7 @@ import type { OpenChoreoClientApi, ActionInfo, ComponentReleaseResponse, + ComponentReleasesResponse, CreateReleaseResponse, SchemaResponse, ReleaseBindingsResponse, @@ -68,6 +69,7 @@ const API_ENDPOINTS = { RESOURCE_ENVIRONMENT_INFO: '/resource-environment-info', UPDATE_RESOURCE_RELEASE_BINDING: '/update-resource-release-binding', DELETE_RESOURCE_RELEASE_BINDING: '/delete-resource-release-binding', + COMPONENT_RELEASES: '/component-releases', UPDATE_RELEASE_BINDING: '/update-release-binding', PATCH_RELEASE_BINDING: '/patch-release-binding', RESOURCE_TREE: '/resourcetree', @@ -504,6 +506,22 @@ export class OpenChoreoClient implements OpenChoreoClientApi { }); } + async listComponentReleases( + entity: Entity, + ): Promise { + const metadata = extractEntityMetadata(entity); + + return this.apiFetch( + API_ENDPOINTS.COMPONENT_RELEASES, + { + params: { + componentName: metadata.component, + namespaceName: metadata.namespace, + }, + }, + ); + } + async updateReleaseBinding( entity: Entity, environment: string, diff --git a/plugins/openchoreo/src/api/OpenChoreoClientApi.ts b/plugins/openchoreo/src/api/OpenChoreoClientApi.ts index b44baced7..a7f49f1d3 100644 --- a/plugins/openchoreo/src/api/OpenChoreoClientApi.ts +++ b/plugins/openchoreo/src/api/OpenChoreoClientApi.ts @@ -233,6 +233,14 @@ export interface ComponentReleaseResponse { data?: ComponentRelease; } +/** Component releases list response */ +export interface ComponentReleasesResponse { + success: boolean; + data?: { + items: ComponentRelease[]; + }; +} + /** Workflow schema response */ export interface WorkflowSchemaResponse { success: boolean; @@ -662,6 +670,9 @@ export interface OpenChoreoClientApi { environment: string, ): Promise; + /** List all component releases for a component (sorted newest first by caller) */ + listComponentReleases(entity: Entity): Promise; + /** Create or update a release binding for deploy/promote actions */ updateReleaseBinding( entity: Entity, diff --git a/plugins/openchoreo/src/components/Environments/EnvironmentOverridesPage.tsx b/plugins/openchoreo/src/components/Environments/EnvironmentOverridesPage.tsx index 6aed3c97e..2b22a2425 100644 --- a/plugins/openchoreo/src/components/Environments/EnvironmentOverridesPage.tsx +++ b/plugins/openchoreo/src/components/Environments/EnvironmentOverridesPage.tsx @@ -685,6 +685,35 @@ export const EnvironmentOverridesPage = ({ return `Fill in the required fields below before promoting to ${pendingAction.targetEnvironment}.`; }; + // Title reflects the user's intent (deploy / promote) so they don't lose + // context on the overrides page. Falls back to today's "Configure overrides" + // wording when there's no pending action (editing an existing binding). + const getPageTitle = () => { + if (!pendingAction) return 'Configure overrides'; + const blocking = missingRequiredFields.length > 0; + if (blocking) + return `Required overrides for ${pendingAction.targetEnvironment}`; + return pendingAction.type === 'deploy' + ? `Deploy to ${pendingAction.targetEnvironment}` + : `Promote to ${pendingAction.targetEnvironment}`; + }; + + const getPageSubtitle = () => { + if (!pendingAction) return environment.name; + const blocking = missingRequiredFields.length > 0; + if (blocking) { + return pendingAction.type === 'deploy' + ? 'Fill in the required fields below before deploying.' + : 'Fill in the required fields below before promoting.'; + } + if (pendingAction.type === 'promote' && pendingAction.sourceEnvironment) { + return `Review environment overrides in ${pendingAction.targetEnvironment} before promoting from ${pendingAction.sourceEnvironment}.`; + } + return pendingAction.type === 'deploy' + ? `Review environment overrides in ${pendingAction.targetEnvironment} before deploying.` + : `Review environment overrides in ${pendingAction.targetEnvironment} before promoting.`; + }; + // Header actions - show when content exists OR when there's a pending action (for Skip & Deploy) const showActions = !loading && !error && (hasAnyContent || pendingAction); const headerActions = showActions ? ( @@ -698,8 +727,14 @@ export const EnvironmentOverridesPage = ({ Delete All )} - {pendingAction?.type === 'promote' && pendingAction.releaseName && ( - + {pendingAction?.releaseName && environment.deployment.releaseName && ( + + + + + ); +}; diff --git a/plugins/openchoreo/src/components/Environments/components/DeployReleasePanel.tsx b/plugins/openchoreo/src/components/Environments/components/DeployReleasePanel.tsx new file mode 100644 index 000000000..048ec05b9 --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/components/DeployReleasePanel.tsx @@ -0,0 +1,194 @@ +import { useEffect, useState } from 'react'; +import { Box, Button, Tooltip, Typography } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { Alert } from '@material-ui/lab'; +import AddIcon from '@material-ui/icons/Add'; +import type { ComponentRelease } from '@openchoreo/backstage-plugin-common'; +import { useEnvironmentRouting } from '../hooks/useEnvironmentRouting'; +import { ReleasePicker, type ReleaseDeployments } from './ReleasePicker'; +import { ReleaseBrowserDialog } from './ReleaseBrowserDialog'; + +const useStyles = makeStyles(theme => ({ + panel: { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1.5), + }, + header: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: theme.spacing(1.5), + }, + headerActions: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.75), + flexShrink: 0, + }, + actionsRow: { + display: 'flex', + justifyContent: 'flex-end', + gap: theme.spacing(1), + }, +})); + +export interface DeployReleasePanelProps { + releases: ComponentRelease[]; + releasesLoading: boolean; + releasesError: string | null; + /** Map release name → environments where it is currently bound. */ + deployments: ReleaseDeployments; + /** Externally controlled selection (so dialogs can preselect newly created release). */ + selectedReleaseName: string | null; + onSelectedReleaseChange: (releaseName: string | null) => void; + /** Display name of the first env (e.g. "development"). */ + firstEnvironmentName: string; + disabled?: boolean; + disabledReason?: string; + /** Forwarded to ReleasePicker to render an inline "+ Create release" button. */ + onCreateRelease?: () => void; + canCreateRelease?: boolean; + createDisabledReason?: string; +} + +/** + * Story 2: pick an existing release and deploy it to the first environment. + * + * The Deploy button always navigates to the existing /overrides/ page + * with a 'deploy' pendingAction; that page reviews overrides and triggers + * updateReleaseBinding on save. There is no "deploy without reviewing + * overrides" shortcut — going through overrides is the canonical path. + */ +export const DeployReleasePanel = ({ + releases, + releasesLoading, + releasesError, + deployments, + selectedReleaseName, + onSelectedReleaseChange, + firstEnvironmentName, + disabled, + disabledReason, + onCreateRelease, + canCreateRelease, + createDisabledReason, +}: DeployReleasePanelProps) => { + const classes = useStyles(); + const { navigateToOverrides } = useEnvironmentRouting(); + const [browserOpen, setBrowserOpen] = useState(false); + + // Preselect the most recent release when nothing is selected yet. Only + // react to the newest name changing so the user's explicit selection + // survives refetches. + const newestName = releases[0]?.metadata?.name ?? null; + useEffect(() => { + if (!selectedReleaseName && newestName) { + onSelectedReleaseChange(newestName); + } + }, [newestName, selectedReleaseName, onSelectedReleaseChange]); + + const handleDeploy = () => { + if (!selectedReleaseName) return; + navigateToOverrides(firstEnvironmentName, { + type: 'deploy', + releaseName: selectedReleaseName, + targetEnvironment: firstEnvironmentName, + }); + }; + + const noReleases = !releasesLoading && releases.length === 0; + // `deployments` keeps env names in their original casing (e.g. "Development") + // while `firstEnvironmentName` is lowercased upstream, so compare loosely. + const targetEnv = firstEnvironmentName.toLowerCase(); + const alreadyDeployed = + !!selectedReleaseName && + (deployments[selectedReleaseName] ?? []).some( + e => e.toLowerCase() === targetEnv, + ); + const deployDisabled = + disabled || !selectedReleaseName || noReleases || alreadyDeployed; + + const getTooltip = () => { + if (disabled && disabledReason) return disabledReason; + if (!selectedReleaseName) return 'Pick a release first'; + if (alreadyDeployed) { + return `This release is already deployed to ${firstEnvironmentName}.`; + } + return ''; + }; + + return ( + + + Deploy + + {onCreateRelease && ( + + + + + + )} + {!(noReleases && onCreateRelease) && ( + + )} + + + + Pick a release and deploy it to {firstEnvironmentName}. + + + {releasesError && {releasesError}} + + + + + + + + + + + + setBrowserOpen(false)} + releases={releases} + deployments={deployments} + selectedReleaseName={selectedReleaseName} + onConfirm={name => onSelectedReleaseChange(name)} + environmentName={firstEnvironmentName} + loading={releasesLoading} + /> + + ); +}; diff --git a/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.test.tsx b/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.test.tsx index 695b27eb8..dffa7e8ea 100644 --- a/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.test.tsx +++ b/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.test.tsx @@ -460,7 +460,7 @@ describe('EnvironmentDetailPanel', () => { expect(screen.queryByText(/rel-7/)).toBeNull(); }); - it('opens the release diff dialog from the drift "View diff" button', async () => { + it('opens the release diff dialog from the drift "View release diff" button', async () => { const user = userEvent.setup(); renderPanel({ selection: { @@ -476,7 +476,9 @@ describe('EnvironmentDetailPanel', () => { aheadUpstreams: [{ envName: 'dev', releaseName: 'rel-7' }], }, }); - await user.click(screen.getByRole('button', { name: /view diff/i })); + await user.click( + screen.getByRole('button', { name: /view release diff/i }), + ); expect(await screen.findByTestId('diff-dialog')).toBeInTheDocument(); }); diff --git a/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.tsx b/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.tsx index 358dc5b01..bdc6bffea 100644 --- a/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.tsx +++ b/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.tsx @@ -358,7 +358,7 @@ export const EnvironmentDetailPanel = ({ startIcon={} onClick={() => setDiffOpen(true)} > - View diff + View release diff )} diff --git a/plugins/openchoreo/src/components/Environments/components/ReleaseBrowserDialog.test.tsx b/plugins/openchoreo/src/components/Environments/components/ReleaseBrowserDialog.test.tsx new file mode 100644 index 000000000..375390e00 --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/components/ReleaseBrowserDialog.test.tsx @@ -0,0 +1,224 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ComponentRelease } from '@openchoreo/backstage-plugin-common'; +import { ReleaseBrowserDialog } from './ReleaseBrowserDialog'; + +const mockFetchComponentRelease = jest.fn(); +const mockApi = { fetchComponentRelease: mockFetchComponentRelease }; + +jest.mock('@backstage/core-plugin-api', () => ({ + useApi: () => mockApi, + createApiRef: jest.fn(), +})); + +const mockEntity = { metadata: { name: 'my-component' } }; +jest.mock('@backstage/plugin-catalog-react', () => ({ + useEntity: () => ({ entity: mockEntity }), +})); + +jest.mock('@openchoreo/backstage-design-system', () => ({ + YamlViewer: ({ value }: { value: string }) => ( +
{value}
+ ), +})); + +// The plugin-react entry pulls Backstage's TabbedLayout transitively which +// blows up under jest's isolated module env. Only YamlDiffViewer is used +// here; mock the package surface to that one component. +jest.mock('@openchoreo/backstage-plugin-react', () => ({ + YamlDiffViewer: ({ + original, + modified, + originalLabel, + modifiedLabel, + }: { + original: string; + modified: string; + originalLabel?: string; + modifiedLabel?: string; + }) => ( +
+
{originalLabel}
+
{modifiedLabel}
+
{original}
+
{modified}
+
+ ), +})); + +jest.mock('yaml', () => ({ + stringify: (obj: unknown) => + `name: ${ + (obj as { metadata?: { name?: string } })?.metadata?.name ?? 'unknown' + }`, +})); + +const makeRelease = ( + name: string, + opts: { image?: string; created?: string } = {}, +): ComponentRelease => + ({ + apiVersion: 'openchoreo.dev/v1alpha1', + kind: 'ComponentRelease', + metadata: { + name, + creationTimestamp: opts.created ?? new Date().toISOString(), + }, + spec: { + workload: { + spec: { container: { image: opts.image ?? 'ghcr.io/x/img:1' } }, + }, + }, + } as unknown as ComponentRelease); + +const releases: ComponentRelease[] = [ + makeRelease('rel-newest', { image: 'ghcr.io/x/svc:3' }), + makeRelease('rel-middle', { image: 'ghcr.io/x/svc:2' }), + makeRelease('rel-oldest', { image: 'ghcr.io/x/svc:1' }), +]; + +describe('ReleaseBrowserDialog', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFetchComponentRelease.mockResolvedValue({ + success: true, + data: { metadata: { name: 'rel-newest' } }, + }); + }); + + const renderDialog = ( + overrides: Partial> = {}, + ) => + render( + , + ); + + it('highlights the currently selected release by default and loads its YAML', async () => { + renderDialog(); + + // Header of the right pane shows the highlighted name. + await waitFor(() => { + expect( + screen.getByRole('heading', { level: 6, name: 'rel-middle' }), + ).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(mockFetchComponentRelease).toHaveBeenCalledWith( + expect.any(Object), + 'rel-middle', + ); + }); + expect(await screen.findByTestId('yaml-viewer')).toBeInTheDocument(); + }); + + it('filters the list by search query', async () => { + const user = userEvent.setup(); + renderDialog(); + + expect(screen.getByTestId('release-row-rel-newest')).toBeInTheDocument(); + expect(screen.getByTestId('release-row-rel-oldest')).toBeInTheDocument(); + + await user.type(screen.getByPlaceholderText(/search by name/i), 'oldest'); + + expect(screen.queryByTestId('release-row-rel-newest')).toBeNull(); + expect(screen.getByTestId('release-row-rel-oldest')).toBeInTheDocument(); + }); + + it('double-clicking a row confirms and closes', async () => { + const onConfirm = jest.fn(); + const onClose = jest.fn(); + const user = userEvent.setup(); + renderDialog({ onConfirm, onClose }); + + await user.dblClick(screen.getByTestId('release-row-rel-oldest')); + + expect(onConfirm).toHaveBeenCalledWith('rel-oldest'); + expect(onClose).toHaveBeenCalled(); + }); + + it('Select is disabled when there are no releases', () => { + renderDialog({ releases: [], selectedReleaseName: null }); + + const select = screen.getByRole('button', { name: 'Select' }); + expect(select).toBeDisabled(); + expect(screen.getByText(/no releases yet/i)).toBeInTheDocument(); + }); + + it('renders the YAML fetch error in the right pane', async () => { + mockFetchComponentRelease.mockRejectedValueOnce(new Error('boom')); + renderDialog({ selectedReleaseName: 'rel-newest' }); + + const error = await screen.findByTestId('yaml-error'); + expect(within(error).getByText(/boom/i)).toBeInTheDocument(); + expect(screen.queryByTestId('yaml-viewer')).toBeNull(); + }); + + it('renders the mode toggle, defaulting to View', async () => { + renderDialog(); + const viewBtn = await screen.findByRole('button', { + name: /view manifest/i, + }); + const compareBtn = screen.getByRole('button', { + name: /compare with another release/i, + }); + expect(viewBtn).toHaveAttribute('aria-pressed', 'true'); + expect(compareBtn).toHaveAttribute('aria-pressed', 'false'); + // YamlViewer renders in view mode; YamlDiffViewer does not. + expect(await screen.findByTestId('yaml-viewer')).toBeInTheDocument(); + expect(screen.queryByTestId('yaml-diff-viewer')).toBeNull(); + }); + + it('switching to Compare pre-fills the env-current release as compare target', async () => { + // `rel-middle` is highlighted (selectedReleaseName) and current in + // development. To make pre-select meaningful, highlight a *different* + // release so the env-current target isn't a self-diff. + const user = userEvent.setup(); + renderDialog({ selectedReleaseName: 'rel-newest' }); + + await user.click( + screen.getByRole('button', { name: /compare with another release/i }), + ); + + // The compare-with selector should now show the env-current release. + const selector = await screen.findByPlaceholderText( + /pick a release or environment/i, + ); + expect(selector).toHaveValue('Current in development'); + + // Both manifests resolve → diff viewer renders. + expect(await screen.findByTestId('yaml-diff-viewer')).toBeInTheDocument(); + expect(screen.getByTestId('diff-original-label')).toHaveTextContent( + /current in development \(rel-middle\)/i, + ); + expect(screen.getByTestId('diff-modified-label')).toHaveTextContent( + /rel-newest/, + ); + }); + + it('Compare with no pre-selectable target shows the empty hint', async () => { + const user = userEvent.setup(); + // No deployments → no env-current pre-select candidate. + renderDialog({ deployments: {} }); + + await user.click( + screen.getByRole('button', { name: /compare with another release/i }), + ); + + expect( + await screen.findByText( + /pick a release or environment to compare against/i, + ), + ).toBeInTheDocument(); + expect(screen.queryByTestId('yaml-diff-viewer')).toBeNull(); + }); +}); diff --git a/plugins/openchoreo/src/components/Environments/components/ReleaseBrowserDialog.tsx b/plugins/openchoreo/src/components/Environments/components/ReleaseBrowserDialog.tsx new file mode 100644 index 000000000..456305cf5 --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/components/ReleaseBrowserDialog.tsx @@ -0,0 +1,808 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + InputAdornment, + List, + ListItem, + ListItemText, + TextField, + Tooltip, + Typography, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { + Autocomplete, + ToggleButton, + ToggleButtonGroup, +} from '@material-ui/lab'; +import CloseIcon from '@material-ui/icons/Close'; +import FileCopyOutlinedIcon from '@material-ui/icons/FileCopyOutlined'; +import SearchIcon from '@material-ui/icons/Search'; +import { useApi } from '@backstage/core-plugin-api'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { YamlViewer } from '@openchoreo/backstage-design-system'; +import { YamlDiffViewer } from '@openchoreo/backstage-plugin-react'; +import YAML from 'yaml'; +import type { ComponentRelease } from '@openchoreo/backstage-plugin-common'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import type { ReleaseDeployments } from './ReleasePicker'; + +const useStyles = makeStyles(theme => ({ + titleBar: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(2), + paddingRight: theme.spacing(1), + }, + titleText: { + flexShrink: 0, + }, + searchField: { + flexGrow: 1, + maxWidth: 320, + }, + closeBtn: { + marginLeft: 'auto', + }, + content: { + display: 'flex', + padding: 0, + height: '60vh', + minHeight: 360, + }, + listPane: { + width: '38%', + minWidth: 260, + borderRight: `1px solid ${theme.palette.divider}`, + overflow: 'auto', + display: 'flex', + flexDirection: 'column', + }, + countLine: { + padding: theme.spacing(1, 2), + color: theme.palette.text.secondary, + fontSize: 12, + }, + detailPane: { + flexGrow: 1, + overflow: 'auto', + padding: theme.spacing(2), + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + }, + listItem: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: theme.spacing(0.5), + }, + listItemName: { + fontWeight: 500, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + width: '100%', + }, + listItemMeta: { + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + gap: theme.spacing(0.75), + color: theme.palette.text.secondary, + fontSize: 12, + }, + chip: { + height: 20, + fontSize: 11, + }, + detailHeader: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: theme.spacing(1), + flexWrap: 'wrap', + }, + detailHeaderTitle: { + display: 'flex', + alignItems: 'baseline', + gap: theme.spacing(1), + flexWrap: 'wrap', + minWidth: 0, + }, + modeToggle: { + flexShrink: 0, + }, + modeButton: { + padding: theme.spacing(0.25, 1.25), + fontSize: 12, + lineHeight: 1.4, + textTransform: 'none', + }, + compareBar: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + }, + compareLabel: { + color: theme.palette.text.secondary, + fontSize: 12, + flexShrink: 0, + }, + compareSelector: { + flexGrow: 1, + minWidth: 0, + }, + metaGrid: { + display: 'grid', + gridTemplateColumns: 'auto 1fr', + columnGap: theme.spacing(2), + rowGap: theme.spacing(0.5), + fontSize: 13, + }, + metaKey: { + color: theme.palette.text.secondary, + }, + yamlHeader: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + marginTop: theme.spacing(1), + }, + empty: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + color: theme.palette.text.secondary, + gap: theme.spacing(1), + padding: theme.spacing(3), + textAlign: 'center', + }, + yamlLoading: { + display: 'flex', + justifyContent: 'center', + padding: theme.spacing(3), + }, + yamlError: { + color: theme.palette.error.main, + }, +})); + +const formatRelativeTime = (iso?: string): string => { + if (!iso) return ''; + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return ''; + const diffSec = Math.max(0, Math.floor((Date.now() - then) / 1000)); + if (diffSec < 60) return 'just now'; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + if (diffDay < 30) return `${diffDay}d ago`; + return new Date(iso).toLocaleDateString(); +}; + +const formatAbsoluteTime = (iso?: string): string => { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + return d.toLocaleString(); +}; + +const extractImage = (release: ComponentRelease): string | undefined => { + const workload = release.spec?.workload as + | { container?: { image?: string } } + | undefined; + return workload?.container?.image; +}; + +const shortenImage = (image: string): string => { + const lastSlash = image.lastIndexOf('/'); + return lastSlash >= 0 ? image.slice(lastSlash + 1) : image; +}; + +export interface ReleaseBrowserDialogProps { + open: boolean; + onClose: () => void; + releases: ComponentRelease[]; + /** Map releaseName → environments where it is currently bound. */ + deployments: ReleaseDeployments; + /** Currently committed selection (used as the initial highlight). */ + selectedReleaseName: string | null; + onConfirm: (releaseName: string) => void; + /** Env name for context in the title (e.g. "development"). */ + environmentName: string; + /** Suppress the list while parent is fetching. */ + loading?: boolean; + /** + * When true, render as an inspector — no Select button. Used under + * auto-deploy where the user cannot pick a release. + */ + readOnly?: boolean; +} + +interface ManifestState { + yaml?: string; + error?: string; +} + +export const ReleaseBrowserDialog = ({ + open, + onClose, + releases, + deployments, + selectedReleaseName, + onConfirm, + environmentName, + loading, + readOnly, +}: ReleaseBrowserDialogProps) => { + const classes = useStyles(); + const api = useApi(openChoreoClientApiRef); + const { entity } = useEntity(); + + const [query, setQuery] = useState(''); + const [highlightedName, setHighlightedName] = useState(null); + const [mode, setMode] = useState<'view' | 'compare'>('view'); + const [compareTargetName, setCompareTargetName] = useState( + null, + ); + const [manifestCache, setManifestCache] = useState< + Record + >({}); + const [yamlLoading, setYamlLoading] = useState(false); + + // Reset internal state every time the dialog opens so backing out and + // reopening doesn't preserve a stale browse position. + useEffect(() => { + if (!open) return; + setQuery(''); + const fallback = selectedReleaseName ?? releases[0]?.metadata?.name ?? null; + setHighlightedName(fallback); + setMode('view'); + setCompareTargetName(null); + }, [open, selectedReleaseName, releases]); + + // Build env → releaseName map (inverse of `deployments`) so we can offer + // "Current in {env}" entries in the compare selector without an extra + // fetch. Same map also fuels the pre-selection default below. Keys use + // the original casing from `deployments` (e.g. "Development") so the + // option labels read naturally. + const envToRelease = useMemo(() => { + const map: Record = {}; + for (const [relName, envs] of Object.entries(deployments)) { + for (const env of envs) { + // First binding wins if a release somehow appears in multiple envs — + // doesn't actually happen with current semantics but defensive. + if (!map[env]) map[env] = relName; + } + } + return map; + }, [deployments]); + + // When entering Compare mode for the first time, default the compare + // target to the release currently in `environmentName` (the dialog's + // contextual env), provided it isn't the highlighted release itself. + // `deployments` may key envs with their original casing (e.g. + // "Development") while `environmentName` is lowercased upstream — match + // loosely. + useEffect(() => { + if (mode !== 'compare' || compareTargetName) return; + const target = environmentName.toLowerCase(); + const entry = Object.entries(envToRelease).find( + ([env]) => env.toLowerCase() === target, + ); + const candidate = entry?.[1]; + if (candidate && candidate !== highlightedName) { + setCompareTargetName(candidate); + } + }, [mode, compareTargetName, envToRelease, environmentName, highlightedName]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return releases; + return releases.filter(r => { + const name = r.metadata?.name?.toLowerCase() ?? ''; + const image = extractImage(r)?.toLowerCase() ?? ''; + const envs = (deployments[r.metadata?.name ?? ''] ?? []) + .join(' ') + .toLowerCase(); + return name.includes(q) || image.includes(q) || envs.includes(q); + }); + }, [releases, deployments, query]); + + const highlighted = useMemo( + () => releases.find(r => r.metadata?.name === highlightedName) ?? null, + [releases, highlightedName], + ); + + // Fetch the manifest for the highlighted release once. + useEffect(() => { + if (!open || !highlightedName) return undefined; + if (manifestCache[highlightedName]) return undefined; + let cancelled = false; + setYamlLoading(true); + api + .fetchComponentRelease(entity, highlightedName) + .then(response => { + if (cancelled) return; + if (!response?.success || !response.data) { + setManifestCache(prev => ({ + ...prev, + [highlightedName]: { error: 'Release manifest is not available.' }, + })); + return; + } + setManifestCache(prev => ({ + ...prev, + [highlightedName]: { yaml: YAML.stringify(response.data) }, + })); + }) + .catch(e => { + if (cancelled) return; + setManifestCache(prev => ({ + ...prev, + [highlightedName]: { + error: e?.message ?? 'Failed to fetch release manifest', + }, + })); + }) + .finally(() => { + if (!cancelled) setYamlLoading(false); + }); + return () => { + cancelled = true; + }; + }, [api, entity, open, highlightedName, manifestCache]); + + // Mirror fetch for the compare target. Reuses the same manifestCache so + // switching back and forth between view and compare is free, and so the + // compare target's YAML is also available for inline display if needed. + useEffect(() => { + if (!open || mode !== 'compare' || !compareTargetName) return undefined; + if (manifestCache[compareTargetName]) return undefined; + let cancelled = false; + api + .fetchComponentRelease(entity, compareTargetName) + .then(response => { + if (cancelled) return; + if (!response?.success || !response.data) { + setManifestCache(prev => ({ + ...prev, + [compareTargetName]: { + error: 'Release manifest is not available.', + }, + })); + return; + } + setManifestCache(prev => ({ + ...prev, + [compareTargetName]: { yaml: YAML.stringify(response.data) }, + })); + }) + .catch(e => { + if (cancelled) return; + setManifestCache(prev => ({ + ...prev, + [compareTargetName]: { + error: e?.message ?? 'Failed to fetch release manifest', + }, + })); + }); + return () => { + cancelled = true; + }; + }, [api, entity, open, mode, compareTargetName, manifestCache]); + + const handleConfirm = () => { + if (!highlightedName) return; + onConfirm(highlightedName); + onClose(); + }; + + const handleCopy = async () => { + const yamlText = highlightedName && manifestCache[highlightedName]?.yaml; + if (!yamlText) return; + try { + await navigator.clipboard.writeText(yamlText); + } catch { + // Best-effort — clipboard access may be unavailable. + } + }; + + const noReleases = !loading && releases.length === 0; + const currentManifest = highlightedName + ? manifestCache[highlightedName] + : undefined; + const compareManifest = compareTargetName + ? manifestCache[compareTargetName] + : undefined; + + // Options for the compare-with selector. Grouped by section: env current + // bindings first, then individual releases. The highlighted release is + // filtered out everywhere — diffing a release against itself is useless. + type CompareOption = { + releaseName: string; + group: 'Currently deployed' | 'Other releases'; + label: string; + sublabel?: string; + }; + const compareOptions = useMemo(() => { + const opts: CompareOption[] = []; + const seenInEnvGroup = new Set(); + for (const [env, relName] of Object.entries(envToRelease)) { + if (relName === highlightedName) continue; + opts.push({ + releaseName: relName, + group: 'Currently deployed', + label: `Current in ${env}`, + sublabel: relName, + }); + seenInEnvGroup.add(relName); + } + for (const r of releases) { + const name = r.metadata?.name; + if (!name || name === highlightedName) continue; + // Avoid duplicating a release that already appears in the env group — + // the env entry is more informative. + if (seenInEnvGroup.has(name)) continue; + opts.push({ + releaseName: name, + group: 'Other releases', + label: name, + sublabel: formatRelativeTime(r.metadata?.creationTimestamp), + }); + } + return opts; + }, [envToRelease, releases, highlightedName]); + const selectedCompareOption = + compareOptions.find(o => o.releaseName === compareTargetName) ?? null; + + return ( + + + + + {readOnly ? 'Releases' : 'Select release'} + + setQuery(e.target.value)} + disabled={noReleases} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + + + + + + {noReleases ? ( + + No releases yet + + Create a release to deploy it to {environmentName}. + + + ) : ( + <> + + + Showing {filtered.length} of {releases.length} + + + {filtered.map(release => { + const name = release.metadata?.name ?? '(unnamed)'; + const created = formatRelativeTime( + release.metadata?.creationTimestamp, + ); + const image = extractImage(release); + const deployedIn = deployments[name] ?? []; + return ( + setHighlightedName(name)} + onDoubleClick={() => { + if (readOnly) { + setHighlightedName(name); + return; + } + setHighlightedName(name); + onConfirm(name); + onClose(); + }} + data-testid={`release-row-${name}`} + > + + + {name} + + + {created && {created}} + {image && img: {shortenImage(image)}} + {deployedIn.map(env => ( + + ))} + + + } + /> + + ); + })} + {filtered.length === 0 && ( + + + No matches. + + + )} + +
+ + + {highlighted ? ( + <> + + + + {highlighted.metadata?.name} + + {( + deployments[highlighted.metadata?.name ?? ''] ?? [] + ).map(env => ( + + ))} + + { + if (next === 'view' || next === 'compare') + setMode(next); + }} + className={classes.modeToggle} + aria-label="Detail pane mode" + > + + View + + + Compare + + + + + + Created + + {formatAbsoluteTime( + highlighted.metadata?.creationTimestamp, + )}{' '} + ( + {formatRelativeTime( + highlighted.metadata?.creationTimestamp, + )} + ) + + {extractImage(highlighted) && ( + <> + Image + {extractImage(highlighted)} + + )} + + + {mode === 'view' ? ( + <> + + Manifest + + + + + + + {yamlLoading && !currentManifest && ( + + + + )} + {currentManifest?.error && ( + + {currentManifest.error} + + )} + {currentManifest?.yaml && ( + + )} + + ) : ( + <> + + + Compare with + + + className={classes.compareSelector} + size="small" + options={compareOptions} + value={selectedCompareOption} + getOptionLabel={o => o.label} + groupBy={o => o.group} + renderOption={o => ( + + {o.label} + {o.sublabel && ( + + {o.sublabel} + + )} + + )} + onChange={(_, next) => + setCompareTargetName(next?.releaseName ?? null) + } + renderInput={params => ( + + )} + /> + + {!compareTargetName && ( + + + Pick a release or environment to compare against. + + + )} + {compareTargetName && + (!currentManifest?.yaml || !compareManifest?.yaml) && + !currentManifest?.error && + !compareManifest?.error && ( + + + + )} + {(currentManifest?.error || compareManifest?.error) && ( + + {currentManifest?.error ?? compareManifest?.error} + + )} + {compareTargetName && + currentManifest?.yaml && + compareManifest?.yaml && ( + + )} + + )} + + ) : ( + + + Pick a release on the left to see details. + + + )} + + + )} + + + + + {!readOnly && ( + + )} + + + ); +}; diff --git a/plugins/openchoreo/src/components/Environments/components/ReleasePicker.tsx b/plugins/openchoreo/src/components/Environments/components/ReleasePicker.tsx new file mode 100644 index 000000000..9bb662476 --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/components/ReleasePicker.tsx @@ -0,0 +1,133 @@ +import { useMemo } from 'react'; +import { Box, Chip, Typography } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { Skeleton } from '@material-ui/lab'; +import type { ComponentRelease } from '@openchoreo/backstage-plugin-common'; + +const useStyles = makeStyles(theme => ({ + summaryRow: { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.75), + padding: theme.spacing(1.25, 1.5), + backgroundColor: theme.palette.action.hover, + borderRadius: 6, + }, + name: { + fontWeight: 500, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + minWidth: 0, + }, + meta: { + color: theme.palette.text.secondary, + fontSize: 12, + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + gap: theme.spacing(0.75), + }, + chip: { + height: 20, + fontSize: 11, + }, + empty: { + color: theme.palette.text.secondary, + fontStyle: 'italic', + }, +})); + +/** Map from releaseName → list of environment names where it is currently bound. */ +export type ReleaseDeployments = Record; + +export interface ReleasePickerProps { + releases: ComponentRelease[]; + selectedReleaseName: string | null; + /** Environments where each release is currently deployed. Used for badges. */ + deployments?: ReleaseDeployments; + loading?: boolean; +} + +const formatRelativeTime = (iso?: string): string => { + if (!iso) return ''; + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return ''; + const diffSec = Math.max(0, Math.floor((Date.now() - then) / 1000)); + if (diffSec < 60) return 'just now'; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + if (diffDay < 30) return `${diffDay}d ago`; + return new Date(iso).toLocaleDateString(); +}; + +const extractImage = (release: ComponentRelease): string | undefined => { + const workload = release.spec?.workload as + | { container?: { image?: string } } + | undefined; + return workload?.container?.image; +}; + +const shortenImage = (image: string): string => { + const lastSlash = image.lastIndexOf('/'); + return lastSlash >= 0 ? image.slice(lastSlash + 1) : image; +}; + +export const ReleasePicker = ({ + releases, + selectedReleaseName, + deployments = {}, + loading, +}: ReleasePickerProps) => { + const classes = useStyles(); + + const selected = useMemo( + () => releases.find(r => r.metadata?.name === selectedReleaseName) ?? null, + [releases, selectedReleaseName], + ); + + const noReleases = !loading && releases.length === 0; + const created = selected + ? formatRelativeTime(selected.metadata?.creationTimestamp) + : ''; + const image = selected ? extractImage(selected) : undefined; + const deployedIn = selected + ? deployments[selected.metadata?.name ?? ''] ?? [] + : []; + + if (loading) { + return ; + } + + return ( + + {selected ? ( + + {selected.metadata?.name} + + ) : ( + + {noReleases ? 'No releases yet' : 'No release selected'} + + )} + {selected && ( + + {created && {created}} + {image && img: {shortenImage(image)}} + {deployedIn.map(env => ( + + ))} + + )} + + ); +}; diff --git a/plugins/openchoreo/src/components/Environments/components/SetupCard.test.tsx b/plugins/openchoreo/src/components/Environments/components/SetupCard.test.tsx index 2d6b000a7..81edae4dc 100644 --- a/plugins/openchoreo/src/components/Environments/components/SetupCard.test.tsx +++ b/plugins/openchoreo/src/components/Environments/components/SetupCard.test.tsx @@ -1,245 +1,38 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { MemoryRouter } from 'react-router-dom'; -import { TestApiProvider } from '@backstage/test-utils'; -import { EntityProvider } from '@backstage/plugin-catalog-react'; -import { - createMockOpenChoreoClient, - mockComponentEntity, -} from '@openchoreo/test-utils'; -import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import { render, screen } from '@testing-library/react'; import { SetupCard } from './SetupCard'; -// ---- Mocks ---- - jest.mock('./LoadingSkeleton', () => ({ LoadingSkeleton: ({ variant }: { variant: string }) => (
), })); -jest.mock('../Workload/WorkloadButton', () => ({ - WorkloadButton: ({ onConfigureWorkload }: any) => ( - - ), -})); - -const mockUpdateAutoDeploy = jest.fn(); -jest.mock('../hooks/useAutoDeployUpdate', () => ({ - useAutoDeployUpdate: () => ({ - updateAutoDeploy: mockUpdateAutoDeploy, - isUpdating: false, - error: null, - }), -})); - -const mockShowSuccess = jest.fn(); -const mockShowError = jest.fn(); -jest.mock('../../../hooks', () => ({ - useNotification: () => ({ - notification: null, - showSuccess: mockShowSuccess, - showError: mockShowError, - hide: jest.fn(), - }), -})); - -// ---- Helpers ---- - -const mockClient = createMockOpenChoreoClient(); -const testEntity = mockComponentEntity(); - -function renderSetupCard( - props: Partial> = {}, -) { - const defaultProps = { - loading: false, - environmentsExist: true, - isWorkloadEditorSupported: false, - onConfigureWorkload: jest.fn(), - }; - - return render( - - - - - - - , - ); -} - -// ---- Tests ---- - -describe('SetupCard', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockClient.getComponentDetails.mockResolvedValue({}); - }); - - it('shows loading skeleton when loading with no environments', () => { - renderSetupCard({ loading: true, environmentsExist: false }); +describe('SetupCard (compact canvas tile)', () => { + it('shows the loading skeleton while no environments are loaded yet', () => { + render( + , + ); expect(screen.getByTestId('loading-skeleton-setup')).toBeInTheDocument(); - expect(screen.queryByText('Auto Deploy')).not.toBeInTheDocument(); - }); - - it('shows content when loaded', () => { - renderSetupCard(); - - expect(screen.getByText('Set up')).toBeInTheDocument(); - expect( - screen.getByText('Manage deployment configuration and settings'), - ).toBeInTheDocument(); - expect(screen.getByText('Auto Deploy')).toBeInTheDocument(); - }); - - it('fetches and displays autoDeploy=true from component details', async () => { - mockClient.getComponentDetails.mockResolvedValue({ autoDeploy: true }); - - renderSetupCard(); - - await waitFor(() => { - const switchEl = screen.getByRole('checkbox', { name: /auto deploy/i }); - expect(switchEl).toBeChecked(); - }); - }); - - it('fetches and displays autoDeploy=false from component details', async () => { - mockClient.getComponentDetails.mockResolvedValue({ autoDeploy: false }); - - renderSetupCard(); - - await waitFor(() => { - const switchEl = screen.getByRole('checkbox', { name: /auto deploy/i }); - expect(switchEl).not.toBeChecked(); - }); - }); - - it('switch defaults to unchecked when autoDeploy is undefined', () => { - mockClient.getComponentDetails.mockResolvedValue({}); - - renderSetupCard(); - - const switchEl = screen.getByRole('checkbox', { name: /auto deploy/i }); - expect(switchEl).not.toBeChecked(); + expect(screen.queryByText('Releases & deployment')).not.toBeInTheDocument(); }); - it('opens confirmation dialog when toggle is clicked', async () => { - const user = userEvent.setup(); - mockClient.getComponentDetails.mockResolvedValue({ autoDeploy: false }); - - renderSetupCard(); - - await waitFor(() => { - expect( - screen.getByRole('checkbox', { name: /auto deploy/i }), - ).not.toBeChecked(); - }); - - await user.click(screen.getByRole('checkbox', { name: /auto deploy/i })); - - expect(screen.getByText('Enable Auto Deploy?')).toBeInTheDocument(); - expect(screen.getByText('Confirm')).toBeInTheDocument(); - expect(screen.getByText('Cancel')).toBeInTheDocument(); - }); - - it('calls updateAutoDeploy on confirm and shows success notification', async () => { - const user = userEvent.setup(); - mockClient.getComponentDetails.mockResolvedValue({ autoDeploy: false }); - mockUpdateAutoDeploy.mockResolvedValue(true); - - renderSetupCard(); - - await waitFor(() => { - expect( - screen.getByRole('checkbox', { name: /auto deploy/i }), - ).not.toBeChecked(); - }); - - await user.click(screen.getByRole('checkbox', { name: /auto deploy/i })); - await user.click(screen.getByText('Confirm')); - - expect(mockUpdateAutoDeploy).toHaveBeenCalledWith(true); - await waitFor(() => { - expect(mockShowSuccess).toHaveBeenCalledWith( - 'Auto deploy enabled successfully', - ); - }); - }); - - it('shows error notification when updateAutoDeploy fails', async () => { - const user = userEvent.setup(); - mockClient.getComponentDetails.mockResolvedValue({ autoDeploy: true }); - mockUpdateAutoDeploy.mockResolvedValue(false); - - renderSetupCard(); - - await waitFor(() => { - expect( - screen.getByRole('checkbox', { name: /auto deploy/i }), - ).toBeChecked(); - }); - - await user.click(screen.getByRole('checkbox', { name: /auto deploy/i })); - await user.click(screen.getByText('Confirm')); - - expect(mockUpdateAutoDeploy).toHaveBeenCalledWith(false); - await waitFor(() => { - expect(mockShowError).toHaveBeenCalledWith( - 'Failed to update auto deploy setting', - ); - }); - }); - - it('closes dialog on cancel without calling updateAutoDeploy', async () => { - const user = userEvent.setup(); - renderSetupCard(); - - await waitFor(() => { - expect( - screen.getByRole('checkbox', { name: /auto deploy/i }), - ).toBeEnabled(); - }); - - await user.click(screen.getByRole('checkbox', { name: /auto deploy/i })); - expect(screen.getByText('Enable Auto Deploy?')).toBeInTheDocument(); - - await user.click(screen.getByText('Cancel')); - - await waitFor(() => { - expect(screen.queryByText('Enable Auto Deploy?')).not.toBeInTheDocument(); - }); - expect(mockUpdateAutoDeploy).not.toHaveBeenCalled(); - }); - - it('shows WorkloadButton when isWorkloadEditorSupported is true', () => { - renderSetupCard({ isWorkloadEditorSupported: true }); - - expect(screen.getByTestId('workload-button')).toBeInTheDocument(); - }); - - it('hides WorkloadButton when isWorkloadEditorSupported is false', () => { - renderSetupCard({ isWorkloadEditorSupported: false }); - - expect(screen.queryByTestId('workload-button')).not.toBeInTheDocument(); - }); - - it('silently handles getComponentDetails failure', async () => { - mockClient.getComponentDetails.mockRejectedValue( - new Error('Network error'), + it('shows the title and hint once loaded', () => { + render( + , ); - renderSetupCard(); - - // Should render normally with switch unchecked (default) - await waitFor(() => { - expect( - screen.getByRole('checkbox', { name: /auto deploy/i }), - ).not.toBeChecked(); - }); + expect(screen.getByText('Set up')).toBeInTheDocument(); + expect(screen.getByText('Releases & deployment')).toBeInTheDocument(); }); }); diff --git a/plugins/openchoreo/src/components/Environments/components/SetupCard.tsx b/plugins/openchoreo/src/components/Environments/components/SetupCard.tsx index 96aa7731c..0e85b411c 100644 --- a/plugins/openchoreo/src/components/Environments/components/SetupCard.tsx +++ b/plugins/openchoreo/src/components/Environments/components/SetupCard.tsx @@ -1,27 +1,17 @@ -import { useState, useEffect, useCallback } from 'react'; -import { - Box, - Typography, - FormControlLabel, - Switch, - Tooltip, - IconButton, -} from '@material-ui/core'; +import { Box, Typography } from '@material-ui/core'; import SettingsOutlinedIcon from '@material-ui/icons/SettingsOutlined'; -import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'; import clsx from 'clsx'; -import { useApi } from '@backstage/core-plugin-api'; -import { useEntity } from '@backstage/plugin-catalog-react'; -import { useSetupCardStyles, useSetupCardCompactStyles } from '../styles'; +import { useSetupCardCompactStyles } from '../styles'; import { SetupCardProps } from '../types'; import { LoadingSkeleton } from './LoadingSkeleton'; -import { WorkloadButton } from '../Workload/WorkloadButton'; -import { AutoDeployConfirmationDialog } from './AutoDeployConfirmationDialog'; -import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; -import { useAutoDeployUpdate } from '../hooks/useAutoDeployUpdate'; -import { useNotification } from '../../../hooks'; -const CompactSetupTile = ({ +/** + * Compact passive tile rendered on the deploy minimap canvas. All Setup + * actions (Auto Deploy, Create release, Edit workload, Deploy) live in the + * right-pane SetupDetailPane when the tile is selected — the canvas stays + * action-free. + */ +export const SetupCard = ({ loading, environmentsExist, selected, @@ -33,6 +23,7 @@ const CompactSetupTile = ({ [classes.cardSelected]: selected, })} > + Start Set up @@ -40,170 +31,8 @@ const CompactSetupTile = ({ {loading && !environmentsExist ? ( ) : ( - - Auto Deploy & component configuration - + Releases & deployment )} ); }; - -const FullSetupCard = ({ - loading, - environmentsExist, - isWorkloadEditorSupported, - onConfigureWorkload, -}: SetupCardProps) => { - const fullStyles = useSetupCardStyles(); - const { entity } = useEntity(); - const client = useApi(openChoreoClientApiRef); - const notification = useNotification(); - const { updateAutoDeploy, isUpdating: autoDeployUpdating } = - useAutoDeployUpdate(entity); - - const [autoDeploy, setAutoDeploy] = useState(undefined); - const [autoDeployLoaded, setAutoDeployLoaded] = useState(false); - const [showConfirmDialog, setShowConfirmDialog] = useState(false); - const [pendingAutoDeployValue, setPendingAutoDeployValue] = useState(false); - - useEffect(() => { - let cancelled = false; - setAutoDeployLoaded(false); - - const fetchComponentData = async () => { - try { - const componentData = await client.getComponentDetails(entity); - if (!cancelled && componentData?.autoDeploy !== undefined) { - setAutoDeploy(componentData.autoDeploy); - } - } catch { - return; - } - if (!cancelled) { - setAutoDeployLoaded(true); - } - }; - - fetchComponentData(); - return () => { - cancelled = true; - }; - }, [entity, client]); - - const handleAutoDeployChange = useCallback( - async (newAutoDeploy: boolean) => { - const success = await updateAutoDeploy(newAutoDeploy); - if (success) { - setAutoDeploy(newAutoDeploy); - notification.showSuccess( - `Auto deploy ${newAutoDeploy ? 'enabled' : 'disabled'} successfully`, - ); - } else { - notification.showError('Failed to update auto deploy setting'); - } - }, - [updateAutoDeploy, notification], - ); - - const handleToggleChange = (event: React.ChangeEvent) => { - const newValue = event.target.checked; - setPendingAutoDeployValue(newValue); - setShowConfirmDialog(true); - }; - - const handleConfirm = () => { - handleAutoDeployChange(pendingAutoDeployValue); - setShowConfirmDialog(false); - }; - - const handleCancel = () => { - setShowConfirmDialog(false); - }; - - const autoDeploySwitch = ( - - } - label={Auto Deploy} - /> - ); - - const autoDeployTooltip = ( - - - - - - ); - - return ( - <> - - - - - Set up - - - {loading && !environmentsExist ? ( - - ) : ( - <> - - Manage deployment configuration and settings - - - - - {autoDeploySwitch} - {autoDeployTooltip} - - - - {isWorkloadEditorSupported && ( - - )} - - )} - - - - - - ); -}; - -/** - * Setup card showing workload deployment options and auto deploy toggle. - * - * Compact mode renders a passive tile for the deploy minimap canvas — the - * Auto Deploy switch and Configure & Deploy button live in the right-pane - * SetupDetailPane when the user clicks the tile, so the canvas stays - * action-free. - */ -export const SetupCard = (props: SetupCardProps) => { - if (props.compact) { - return ; - } - return ; -}; diff --git a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.test.tsx b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.test.tsx new file mode 100644 index 000000000..bb3e9d634 --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.test.tsx @@ -0,0 +1,283 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { TestApiProvider } from '@backstage/test-utils'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { + createMockOpenChoreoClient, + mockComponentEntity, +} from '@openchoreo/test-utils'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import { SetupDetailPane } from './SetupDetailPane'; + +// ---- Mocks ---- + +jest.mock('./LoadingSkeleton', () => ({ + LoadingSkeleton: ({ variant }: { variant: string }) => ( +
+ ), +})); + +jest.mock('./DeployReleasePanel', () => ({ + DeployReleasePanel: ({ + disabled, + onCreateRelease, + canCreateRelease, + }: any) => ( +
+ {onCreateRelease && ( + + )} +
+ ), +})); + +jest.mock('./ReleaseBrowserDialog', () => ({ + ReleaseBrowserDialog: ({ open, readOnly }: any) => + open ? ( +
+ ) : null, +})); + +const mockUpdateAutoDeploy = jest.fn(); +jest.mock('../hooks/useAutoDeployUpdate', () => ({ + useAutoDeployUpdate: () => ({ + updateAutoDeploy: mockUpdateAutoDeploy, + isUpdating: false, + error: null, + }), +})); + +let readinessOverride: any = null; +jest.mock('../hooks/useReleaseReadiness', () => ({ + useReleaseReadiness: () => + readinessOverride ?? { + loading: false, + canCreateRelease: true, + alertMessage: null, + alertSeverity: 'info', + hasWorkload: true, + isFromSource: false, + }, +})); + +jest.mock('../hooks/useReleases', () => ({ + useReleases: () => ({ + releases: [], + loading: false, + error: null, + refetch: jest.fn(), + }), +})); + +let permissionOverride: any = null; +jest.mock('@openchoreo/backstage-plugin-react', () => { + const actual = jest.requireActual('@openchoreo/backstage-plugin-react'); + return { + ...actual, + useConfigureAndDeployPermission: () => + permissionOverride ?? { + canConfigureAndDeploy: true, + loading: false, + deniedTooltip: '', + }, + }; +}); + +const mockShowSuccess = jest.fn(); +const mockShowError = jest.fn(); +jest.mock('../../../hooks', () => ({ + useNotification: () => ({ + notification: null, + showSuccess: mockShowSuccess, + showError: mockShowError, + hide: jest.fn(), + }), +})); + +const mockRefetchAutoDeploy = jest.fn(); +let contextOverride: Partial<{ + autoDeploy: boolean; + autoDeployLoading: boolean; +}> = {}; +jest.mock('../EnvironmentsContext', () => ({ + useEnvironmentsContext: () => ({ + environments: [{ name: 'development', deployment: {}, endpoints: [] }], + displayEnvironments: [], + loading: false, + refetch: jest.fn(), + lowestEnvironment: 'development', + isWorkloadEditorSupported: true, + onPendingActionComplete: jest.fn(), + canViewEnvironments: true, + environmentReadPermissionLoading: false, + canViewBindings: true, + bindingsPermissionLoading: false, + autoDeploy: false, + autoDeployLoading: false, + refetchAutoDeploy: mockRefetchAutoDeploy, + selection: null, + setSelection: jest.fn(), + ...contextOverride, + }), +})); + +// ---- Helpers ---- + +const mockClient = createMockOpenChoreoClient(); +const testEntity = mockComponentEntity(); + +const renderPane = ( + props: Partial> = {}, +) => + render( + + + + + + + , + ); + +beforeEach(() => { + jest.clearAllMocks(); + readinessOverride = null; + permissionOverride = null; + contextOverride = {}; + mockClient.getComponentDetails.mockResolvedValue({ autoDeploy: false }); +}); + +describe('SetupDetailPane', () => { + it('renders the deploy panel with an inline Create release affordance', async () => { + renderPane(); + + expect(screen.getByTestId('deploy-release-panel')).toBeInTheDocument(); + expect( + await screen.findByRole('button', { name: /create release/i }), + ).toBeEnabled(); + }); + + it('Create release navigates to the workload page (onConfigureWorkload)', async () => { + const onConfigureWorkload = jest.fn(); + const user = userEvent.setup(); + renderPane({ onConfigureWorkload }); + + await user.click( + await screen.findByRole('button', { name: /create release/i }), + ); + + expect(onConfigureWorkload).toHaveBeenCalledTimes(1); + }); + + it('disables Create release when readiness blocks it and surfaces the reason', async () => { + readinessOverride = { + loading: false, + canCreateRelease: false, + alertMessage: + 'Build your application first to generate a container image.', + alertSeverity: 'warning', + hasWorkload: false, + isFromSource: true, + }; + + renderPane(); + + const button = await screen.findByRole('button', { + name: /create release/i, + }); + expect(button).toBeDisabled(); + expect( + screen.getByText( + 'Build your application first to generate a container image.', + ), + ).toBeInTheDocument(); + }); + + it('confirms auto-deploy changes through the confirmation dialog', async () => { + const user = userEvent.setup(); + mockUpdateAutoDeploy.mockResolvedValue(true); + renderPane(); + + await waitFor(() => { + expect( + screen.getByRole('checkbox', { name: /auto deploy/i }), + ).not.toBeChecked(); + }); + + await user.click(screen.getByRole('checkbox', { name: /auto deploy/i })); + expect(screen.getByText('Enable Auto Deploy?')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /confirm/i })); + + expect(mockUpdateAutoDeploy).toHaveBeenCalledWith(true); + await waitFor(() => { + expect(mockShowSuccess).toHaveBeenCalledWith( + 'Auto deploy enabled successfully', + ); + }); + }); + + it('hides the deploy panel and shows Configure component when auto-deploy is on', async () => { + contextOverride = { autoDeploy: true }; + renderPane(); + + expect( + await screen.findByRole('button', { name: /configure component/i }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /create release/i }), + ).toBeNull(); + expect(screen.queryByTestId('deploy-release-panel')).toBeNull(); + }); + + it('renders the setup skeleton while auto-deploy is loading', () => { + contextOverride = { autoDeployLoading: true }; + renderPane(); + + expect(screen.getByTestId('loading-skeleton-setup')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /create release/i }), + ).toBeNull(); + expect( + screen.queryByRole('button', { name: /configure component/i }), + ).toBeNull(); + }); + + it('disables actions when the user lacks the deploy permission', async () => { + permissionOverride = { + canConfigureAndDeploy: false, + loading: false, + deniedTooltip: 'You do not have permission to deploy.', + }; + + renderPane(); + + expect( + await screen.findByRole('button', { name: /create release/i }), + ).toBeDisabled(); + expect(screen.getByTestId('deploy-release-panel')).toHaveAttribute( + 'data-disabled', + 'true', + ); + }); +}); diff --git a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx index cdf290270..cbf15934f 100644 --- a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx +++ b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx @@ -1,24 +1,111 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { Box, - Typography, + Button, + Divider, FormControlLabel, + IconButton, Switch, Tooltip, - IconButton, + Typography, } from '@material-ui/core'; -import SettingsOutlinedIcon from '@material-ui/icons/SettingsOutlined'; -import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'; +import { Alert } from '@material-ui/lab'; +import ChevronRightIcon from '@material-ui/icons/ChevronRight'; import CloseIcon from '@material-ui/icons/Close'; -import { useApi } from '@backstage/core-plugin-api'; +import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'; +import SettingsOutlinedIcon from '@material-ui/icons/SettingsOutlined'; import { useEntity } from '@backstage/plugin-catalog-react'; import { useEnvironmentDetailPanelStyles } from '../styles'; import { LoadingSkeleton } from './LoadingSkeleton'; -import { WorkloadButton } from '../Workload/WorkloadButton'; import { AutoDeployConfirmationDialog } from './AutoDeployConfirmationDialog'; -import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import { DeployReleasePanel } from './DeployReleasePanel'; +import { ReleaseBrowserDialog } from './ReleaseBrowserDialog'; +import type { ComponentRelease } from '@openchoreo/backstage-plugin-common'; import { useAutoDeployUpdate } from '../hooks/useAutoDeployUpdate'; +import { useReleases } from '../hooks/useReleases'; +import { useReleaseReadiness } from '../hooks/useReleaseReadiness'; +import { useEnvironmentsContext } from '../EnvironmentsContext'; +import { useConfigureAndDeployPermission } from '@openchoreo/backstage-plugin-react'; import { useNotification } from '../../../hooks'; +import type { ReleaseDeployments } from './ReleasePicker'; + +interface LatestReleaseRowProps { + releases: ComponentRelease[]; + releasesLoading: boolean; + deployments: ReleaseDeployments; + firstEnvironmentName: string; +} + +/** Read-only "Latest release" row used when auto-deploy is on. Clicking opens + * the release browser in read-only mode so the user can inspect YAML without + * being able to pick a release (the controller controls that under auto-deploy). */ +const LatestReleaseRow = ({ + releases, + releasesLoading, + deployments, + firstEnvironmentName, +}: LatestReleaseRowProps) => { + const [browserOpen, setBrowserOpen] = useState(false); + // 'Latest' under auto-deploy = release currently bound to the first env. + // Falls back to the first release in the list (sorted newest-first) when + // no binding exists yet. + const latest = + releases.find(r => + (deployments[r.metadata?.name ?? ''] ?? []).includes( + firstEnvironmentName, + ), + ) ?? + releases[0] ?? + null; + + return ( + + Latest release + {releasesLoading && !latest && ( + + Loading… + + )} + {!releasesLoading && !latest && ( + + No release yet. Auto-deploy will create one after you save the + workload. + + )} + {latest && ( + setBrowserOpen(true)} + display="flex" + alignItems="center" + style={{ cursor: 'pointer', gap: 8 }} + > + + {latest.metadata?.name} + + {(deployments[latest.metadata?.name ?? ''] ?? []).includes( + firstEnvironmentName, + ) && ( + + current in {firstEnvironmentName} + + )} + + + )} + setBrowserOpen(false)} + releases={releases} + deployments={deployments} + selectedReleaseName={latest?.metadata?.name ?? null} + onConfirm={() => {}} + environmentName={firstEnvironmentName} + loading={releasesLoading} + readOnly + /> + + ); +}; export interface SetupDetailPaneProps { environmentsExist: boolean; @@ -29,9 +116,13 @@ export interface SetupDetailPaneProps { } /** - * Right-pane body shown when the canvas Setup tile is selected. Owns the - * Auto Deploy fetch + update flow and renders the Configure & Deploy - * action so the canvas tile itself can stay passive. + * Right-pane body shown when the canvas Setup tile is selected. + * + * Story 1 ("Create a release"): edit workload (via existing config page) and + * snapshot the current state as a named ComponentRelease. + * + * Story 2 ("Deploy a release"): pick from existing releases and deploy to + * the first environment, with the option to configure per-env overrides. */ export const SetupDetailPane = ({ environmentsExist, @@ -42,71 +133,81 @@ export const SetupDetailPane = ({ }: SetupDetailPaneProps) => { const classes = useEnvironmentDetailPanelStyles(); const { entity } = useEntity(); - const client = useApi(openChoreoClientApiRef); const notification = useNotification(); + const { + environments, + lowestEnvironment, + autoDeploy, + autoDeployLoading, + refetchAutoDeploy, + } = useEnvironmentsContext(); const { updateAutoDeploy, isUpdating: autoDeployUpdating } = useAutoDeployUpdate(entity); + const { + canConfigureAndDeploy, + loading: permissionLoading, + deniedTooltip, + } = useConfigureAndDeployPermission(); + const readiness = useReleaseReadiness(entity); - const [autoDeploy, setAutoDeploy] = useState(undefined); - const [autoDeployLoaded, setAutoDeployLoaded] = useState(false); - const [showConfirmDialog, setShowConfirmDialog] = useState(false); - const [pendingAutoDeployValue, setPendingAutoDeployValue] = useState(false); - - useEffect(() => { - let cancelled = false; - setAutoDeployLoaded(false); - - const fetchComponentData = async () => { - try { - const componentData = await client.getComponentDetails(entity); - if (!cancelled && componentData?.autoDeploy !== undefined) { - setAutoDeploy(componentData.autoDeploy); - } - } catch { - // Transient fetch failure — leave autoDeploy undefined and let - // the toggle render with the default. Don't block "loaded". - } finally { - if (!cancelled) { - setAutoDeployLoaded(true); - } - } - }; + const { + releases, + loading: releasesLoading, + error: releasesError, + } = useReleases(entity); - fetchComponentData(); - return () => { - cancelled = true; - }; - }, [entity, client]); + const [showAutoDeployConfirm, setShowAutoDeployConfirm] = useState(false); + const [pendingAutoDeployValue, setPendingAutoDeployValue] = useState(false); + const [selectedReleaseName, setSelectedReleaseName] = useState( + null, + ); const handleAutoDeployChange = useCallback( - async (newAutoDeploy: boolean) => { - const success = await updateAutoDeploy(newAutoDeploy); - if (success) { - setAutoDeploy(newAutoDeploy); + async (next: boolean) => { + const ok = await updateAutoDeploy(next); + if (ok) { + refetchAutoDeploy(); notification.showSuccess( - `Auto deploy ${newAutoDeploy ? 'enabled' : 'disabled'} successfully`, + `Auto deploy ${next ? 'enabled' : 'disabled'} successfully`, ); } else { notification.showError('Failed to update auto deploy setting'); } }, - [updateAutoDeploy, notification], + [updateAutoDeploy, refetchAutoDeploy, notification], ); const handleToggleChange = (event: React.ChangeEvent) => { - const newValue = event.target.checked; - setPendingAutoDeployValue(newValue); - setShowConfirmDialog(true); + setPendingAutoDeployValue(event.target.checked); + setShowAutoDeployConfirm(true); }; - const handleConfirm = () => { + const handleConfirmAutoDeploy = () => { handleAutoDeployChange(pendingAutoDeployValue); - setShowConfirmDialog(false); + setShowAutoDeployConfirm(false); }; - const handleCancel = () => { - setShowConfirmDialog(false); - }; + // Build deployments map: releaseName → [envName, ...] + const deployments: ReleaseDeployments = useMemo(() => { + const map: ReleaseDeployments = {}; + for (const env of environments) { + const name = env.deployment?.releaseName; + if (!name) continue; + if (!map[name]) map[name] = []; + map[name].push(env.name); + } + return map; + }, [environments]); + + const createDisabledReason = (() => { + if (!canConfigureAndDeploy) return deniedTooltip; + if (!readiness.canCreateRelease) { + return readiness.alertMessage ?? 'Not ready to create a release.'; + } + return ''; + })(); + const canCreate = + !permissionLoading && canConfigureAndDeploy && readiness.canCreateRelease; return ( @@ -116,25 +217,24 @@ export const SetupDetailPane = ({ Set up - - - - - + + + - {loading && !environmentsExist ? ( + {(loading && !environmentsExist) || autoDeployLoading ? ( ) : ( <> - Manage component configuration and choose how new versions deploy. + Create releases from your component, then deploy them to{' '} + {lowestEnvironment}. @@ -145,13 +245,13 @@ export const SetupDetailPane = ({ onChange={handleToggleChange} name="autoDeploy" color="primary" - disabled={!autoDeployLoaded || autoDeployUpdating} + disabled={autoDeployLoading || autoDeployUpdating} /> } label={Auto Deploy} /> @@ -161,19 +261,85 @@ export const SetupDetailPane = ({ - {isWorkloadEditorSupported && ( - - - + + + {autoDeploy ? ( + /* Auto-deploy ON: configure component + read-only latest release */ + <> + + Component + + Auto-deploy is on. Saving any configuration change creates a + release automatically and rolls it out to{' '} + {lowestEnvironment}. + + {readiness.alertMessage && ( + + {readiness.alertMessage} + + )} + {isWorkloadEditorSupported && ( + + + + + + + + )} + + + + + + + ) : ( + <> + {readiness.alertMessage && ( + + {readiness.alertMessage} + + )} + + )} )} setShowAutoDeployConfirm(false)} + onConfirm={handleConfirmAutoDeploy} isEnabling={pendingAutoDeployValue} isUpdating={autoDeployUpdating} /> diff --git a/plugins/openchoreo/src/components/Environments/hooks/index.ts b/plugins/openchoreo/src/components/Environments/hooks/index.ts index eeda425fe..98573bc58 100644 --- a/plugins/openchoreo/src/components/Environments/hooks/index.ts +++ b/plugins/openchoreo/src/components/Environments/hooks/index.ts @@ -26,6 +26,13 @@ export { type UsePromotionActionResult, } from './usePromotionAction'; export { useInvokeUrl } from './useInvokeUrl'; +export { useReleases, type UseReleasesResult } from './useReleases'; +export { useAutoDeploy } from './useAutoDeploy'; +export { + useReleaseReadiness, + type UseReleaseReadinessResult, + type ReleaseReadinessAlertSeverity, +} from './useReleaseReadiness'; export { useEnvironmentRouting, type EnvironmentView, diff --git a/plugins/openchoreo/src/components/Environments/hooks/useAutoDeploy.ts b/plugins/openchoreo/src/components/Environments/hooks/useAutoDeploy.ts new file mode 100644 index 000000000..40acba6ea --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/hooks/useAutoDeploy.ts @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useApi } from '@backstage/core-plugin-api'; +import type { Entity } from '@backstage/catalog-model'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; + +/** + * Loads the component's `autoDeploy` flag once via `getComponentDetails` and + * exposes a refetch handle so consumers (e.g. the Setup card toggle) can + * trigger a re-read after they update the value on the server. + * + * Lives at the Environments-page level so all child views (SetupDetailPane, + * WorkloadConfigPage) read a single source of truth and don't render + * auto-deploy-dependent UI against a stale default during initial load. + */ +export const useAutoDeploy = (entity: Entity) => { + const client = useApi(openChoreoClientApiRef); + const [autoDeploy, setAutoDeploy] = useState(false); + const [loading, setLoading] = useState(true); + + const fetchOnce = useCallback(async () => { + setLoading(true); + try { + const componentData = await client.getComponentDetails(entity); + setAutoDeploy(!!componentData?.autoDeploy); + } catch { + // Leave at the previous value — toggle stays in its last-known state. + } finally { + setLoading(false); + } + }, [client, entity]); + + useEffect(() => { + fetchOnce(); + }, [fetchOnce]); + + return { autoDeploy, loading, refetch: fetchOnce }; +}; diff --git a/plugins/openchoreo/src/components/Environments/hooks/useReleaseReadiness.ts b/plugins/openchoreo/src/components/Environments/hooks/useReleaseReadiness.ts new file mode 100644 index 000000000..f553ab62b --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/hooks/useReleaseReadiness.ts @@ -0,0 +1,143 @@ +import { useEffect, useState } from 'react'; +import { Entity } from '@backstage/catalog-model'; +import { + useApi, + discoveryApiRef, + fetchApiRef, +} from '@backstage/core-plugin-api'; +import { ModelsBuild } from '@openchoreo/backstage-plugin-common'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import { isFromSourceComponent } from '../../../utils/componentUtils'; + +export type ReleaseReadinessAlertSeverity = 'error' | 'warning' | 'info'; + +export interface UseReleaseReadinessResult { + loading: boolean; + /** True when a release can be created (workload exists and any required build succeeded). */ + canCreateRelease: boolean; + /** When canCreateRelease is false, a human-readable reason. */ + alertMessage: string | null; + alertSeverity: ReleaseReadinessAlertSeverity; + hasWorkload: boolean; + isFromSource: boolean; +} + +/** + * Determines whether a component is ready for a new release. + * + * Extracted from the old WorkloadButton so both the "Create release" and + * "Edit workload" entry points share the same gating logic. + */ +export const useReleaseReadiness = ( + entity: Entity, +): UseReleaseReadinessResult => { + const discovery = useApi(discoveryApiRef); + const fetchApi = useApi(fetchApiRef); + const client = useApi(openChoreoClientApiRef); + + const [workloadLoading, setWorkloadLoading] = useState(true); + const [hasWorkload, setHasWorkload] = useState(false); + const [builds, setBuilds] = useState([]); + const [buildsLoading, setBuildsLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + setWorkloadLoading(true); + const fetchWorkload = async () => { + try { + await client.fetchWorkloadInfo(entity); + if (!cancelled) setHasWorkload(true); + } catch { + if (!cancelled) setHasWorkload(false); + } finally { + if (!cancelled) setWorkloadLoading(false); + } + }; + fetchWorkload(); + return () => { + cancelled = true; + }; + }, [entity, client]); + + useEffect(() => { + let cancelled = false; + setBuildsLoading(true); + const fetchBuilds = async () => { + try { + const componentName = entity.metadata.name; + const projectName = + entity.metadata.annotations?.['openchoreo.io/project']; + const namespaceName = + entity.metadata.annotations?.['openchoreo.io/namespace']; + const baseUrl = await discovery.getBaseUrl('openchoreo'); + + if (projectName && namespaceName && componentName) { + const response = await fetchApi.fetch( + `${baseUrl}/builds?componentName=${encodeURIComponent( + componentName, + )}&projectName=${encodeURIComponent( + projectName, + )}&namespaceName=${encodeURIComponent(namespaceName)}`, + ); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + const data = await response.json(); + if (!cancelled) setBuilds(data); + } + } catch { + if (!cancelled) setBuilds([]); + } finally { + if (!cancelled) setBuildsLoading(false); + } + }; + fetchBuilds(); + return () => { + cancelled = true; + }; + }, [entity.metadata.name, entity.metadata.annotations, fetchApi, discovery]); + + const isFromSource = isFromSourceComponent(entity); + const hasBuilds = builds.length > 0; + const hasSuccessfulBuild = builds.some(build => !!build.image); + const loading = workloadLoading || buildsLoading; + + const canCreateRelease = (() => { + if (loading) return false; + if (isFromSource) { + return hasBuilds && hasSuccessfulBuild && hasWorkload; + } + return hasWorkload; + })(); + + const alertMessage: string | null = (() => { + if (loading) return null; + if (isFromSource) { + if (!hasBuilds) { + return 'Build your application first to generate a container image.'; + } + if (hasSuccessfulBuild && !hasWorkload) { + return 'Workload configuration was not created automatically. Please re-run the build workflow or contact support.'; + } + } + if (!hasWorkload) { + return 'Configure your workload to enable deployment.'; + } + return null; + })(); + + const alertSeverity: ReleaseReadinessAlertSeverity = (() => { + if (isFromSource && hasSuccessfulBuild && !hasWorkload) return 'error'; + if (isFromSource && !hasBuilds) return 'warning'; + return 'info'; + })(); + + return { + loading, + canCreateRelease, + alertMessage, + alertSeverity, + hasWorkload, + isFromSource, + }; +}; diff --git a/plugins/openchoreo/src/components/Environments/hooks/useReleases.ts b/plugins/openchoreo/src/components/Environments/hooks/useReleases.ts new file mode 100644 index 000000000..ceaa6960d --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/hooks/useReleases.ts @@ -0,0 +1,53 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Entity } from '@backstage/catalog-model'; +import { useApi } from '@backstage/core-plugin-api'; +import type { ComponentRelease } from '@openchoreo/backstage-plugin-common'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; + +export interface UseReleasesResult { + releases: ComponentRelease[]; + loading: boolean; + error: string | null; + refetch: () => Promise; +} + +const getCreationTime = (release: ComponentRelease): number => { + const ts = release.metadata?.creationTimestamp; + return ts ? new Date(ts).getTime() : 0; +}; + +/** + * Fetches the list of ComponentReleases for a component, newest first. + */ +export const useReleases = (entity: Entity): UseReleasesResult => { + const client = useApi(openChoreoClientApiRef); + const [releases, setReleases] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchReleases = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await client.listComponentReleases(entity); + const items = response.data?.items ?? []; + const sorted = [...items].sort( + (a, b) => getCreationTime(b) - getCreationTime(a), + ); + setReleases(sorted); + } catch (e: unknown) { + const message = + e instanceof Error ? e.message : 'Failed to load releases'; + setError(message); + setReleases([]); + } finally { + setLoading(false); + } + }, [client, entity]); + + useEffect(() => { + fetchReleases(); + }, [fetchReleases]); + + return { releases, loading, error, refetch: fetchReleases }; +}; diff --git a/plugins/openchoreo/src/components/Environments/styles.ts b/plugins/openchoreo/src/components/Environments/styles.ts index 8363d4545..caa213f92 100644 --- a/plugins/openchoreo/src/components/Environments/styles.ts +++ b/plugins/openchoreo/src/components/Environments/styles.ts @@ -83,6 +83,7 @@ export const useSetupCardStyles = makeStyles(theme => ({ */ export const useSetupCardCompactStyles = makeStyles(theme => ({ setupCard: { + position: 'relative', backgroundColor: theme.palette.background.paper, color: theme.palette.text.primary, padding: theme.spacing(2), @@ -102,6 +103,20 @@ export const useSetupCardCompactStyles = makeStyles(theme => ({ boxShadow: theme.shadows[3], }, }, + startBadge: { + position: 'absolute', + top: theme.spacing(1), + left: theme.spacing(1), + padding: '1px 6px', + borderRadius: 3, + backgroundColor: alpha(theme.palette.primary.main, 0.15), + color: theme.palette.primary.main, + fontSize: 9, + fontWeight: 700, + letterSpacing: '0.08em', + lineHeight: 1.4, + textTransform: 'uppercase', + }, cardSelected: { borderColor: theme.palette.primary.main, borderStyle: 'solid', diff --git a/plugins/openchoreo/src/components/Environments/wrappers/OverridesWrapper.tsx b/plugins/openchoreo/src/components/Environments/wrappers/OverridesWrapper.tsx index 1da33814c..76186a1dc 100644 --- a/plugins/openchoreo/src/components/Environments/wrappers/OverridesWrapper.tsx +++ b/plugins/openchoreo/src/components/Environments/wrappers/OverridesWrapper.tsx @@ -19,7 +19,7 @@ export const OverridesWrapper = () => { const { entity } = useEntity(); const { environments, refetch, onPendingActionComplete } = useEnvironmentsContext(); - const { navigateToList, navigateToWorkloadConfig } = useEnvironmentRouting(); + const { navigateToList } = useEnvironmentRouting(); // Parse pending action from URL const pendingAction = useMemo( @@ -81,10 +81,10 @@ export const OverridesWrapper = () => { refetch(); }; - const handlePrevious = - pendingAction?.type === 'deploy' - ? () => navigateToWorkloadConfig() - : undefined; + // Deploy and promote flows have no "previous step" in the Setup-card-driven + // UX — both are launched from the deploy list. Leaving onPrevious undefined + // hides the Previous button; users use Back/Cancel to return to the list. + const handlePrevious = undefined; // Error state: environment not found if (!environment) { diff --git a/plugins/openchoreo/src/components/Environments/wrappers/WorkloadConfigWrapper.tsx b/plugins/openchoreo/src/components/Environments/wrappers/WorkloadConfigWrapper.tsx index 493417c28..6c9f8de57 100644 --- a/plugins/openchoreo/src/components/Environments/wrappers/WorkloadConfigWrapper.tsx +++ b/plugins/openchoreo/src/components/Environments/wrappers/WorkloadConfigWrapper.tsx @@ -3,10 +3,13 @@ import { useSearchParams, useNavigate } from 'react-router-dom'; import { useEnvironmentsContext } from '../EnvironmentsContext'; import { useEnvironmentRouting } from '../hooks/useEnvironmentRouting'; import { WorkloadConfigPage } from '../Workload/WorkloadConfigPage'; -import type { PendingAction } from '../types'; /** * Wrapper component for WorkloadConfigPage that handles URL-based navigation. + * + * The page hosts the full Create release flow: review workload + traits + + * parameters, save, then snapshot as a named release. On success, we + * return the user to the deploy list view. */ export const WorkloadConfigWrapper = () => { const [searchParams] = useSearchParams(); @@ -18,42 +21,23 @@ export const WorkloadConfigWrapper = () => { const envDataPlane = lowestEnv ? { kind: lowestEnv.dataPlaneKind, name: lowestEnv.dataPlaneRef } : undefined; - const { navigateToList, navigateToOverrides } = useEnvironmentRouting(); + const { navigateToList } = useEnvironmentRouting(); - // Get active tab from URL (container, endpoints, dependencies) const activeTab = searchParams.get('tab') || 'container'; - // Handle tab change - update URL - // When replace is true (default tab initialization), don't add to history - // When replace is false (user interaction), add to history for back button support const handleTabChange = useCallback( (tab: string, replace = false) => { const newParams = new URLSearchParams(searchParams); - // Always set tab param for consistency (including first tab) newParams.set('tab', tab); navigate(`?${newParams.toString()}`, { replace }); }, [searchParams, navigate], ); - const handleBack = () => { - navigateToList(); - }; - - const handleNext = (releaseName: string, targetEnvironment: string) => { - const pendingAction: PendingAction = { - type: 'deploy', - releaseName, - targetEnvironment, - }; - navigateToOverrides(targetEnvironment, pendingAction); - }; - return (