From 78e9e6e63903361373f70b421e9265258ee61918 Mon Sep 17 00:00:00 2001 From: Jakub Hadvig Date: Wed, 22 Jul 2026 13:47:36 +0200 Subject: [PATCH 1/6] Fix phase derivation, active phase set, and decision display - Add missing execution phases (Approved, Executing, AwaitingSync, Verifying) to ACTIVE_AGENTIC_RUN_PHASES so in-flight runs don't disappear from the Updates plan tab (V2#2) - Detect sandbox allocation in derivePhase() to return Analyzing before the Analyzed condition is set by the operator (V2#3) - Add shared DECISION_DISPLAY map with getDecisionDisplay() helper to remap "block" to "NOT RECOMMENDED" and eliminate duplicated inline decision-color logic (V1#10) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/models/agenticrun.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/models/agenticrun.ts b/src/models/agenticrun.ts index 0b94675..db20093 100644 --- a/src/models/agenticrun.ts +++ b/src/models/agenticrun.ts @@ -102,6 +102,10 @@ export const ACTIVE_AGENTIC_RUN_PHASES = new Set([ 'Analyzing', 'Analysed', 'Proposed', + 'Approved', + 'Executing', + 'AwaitingSync', + 'Verifying', 'Completed', 'Escalated', 'Failed', @@ -142,6 +146,9 @@ export const derivePhase = (agenticRun?: LightspeedAgenticRun): AgenticRunPhase // Check if analysis step has any results (in progress) if (agenticRun?.status?.steps?.analysis?.results?.length) return 'Analyzing'; + // Sandbox allocated or analysis step present means analysis has started + if (agenticRun?.status?.steps?.analysis?.sandbox?.claimName) return 'Analyzing'; + // If any condition is Unknown, the operator is still reconciling if (conditions.some((c: K8sResourceCondition) => c.status === 'Unknown')) return 'Pending'; @@ -186,6 +193,24 @@ export const getPhaseDisplay = (phase?: AgenticRunPhase | string): PhaseDisplay } }; +export type DecisionDisplayEntry = { + label: string; + color: 'green' | 'orange' | 'red' | 'purple'; +}; + +export const DECISION_DISPLAY: Record = { + recommend: { label: 'RECOMMEND', color: 'green' }, + caution: { label: 'CAUTION', color: 'orange' }, + block: { label: 'NOT RECOMMENDED', color: 'red' }, + escalate: { label: 'ESCALATE', color: 'purple' }, +}; + +export const getDecisionDisplay = (decision?: string): DecisionDisplayEntry => + DECISION_DISPLAY[decision?.toLowerCase() ?? ''] ?? { + label: (decision ?? '').toUpperCase(), + color: 'purple' as const, + }; + export const getRiskColor = (risk?: string): 'green' | 'orange' | 'red' | 'grey' => { switch (risk?.toLowerCase()) { case 'low': From 635f4445717de08713970dc4ebab8930a6243882 Mon Sep 17 00:00:00 2001 From: Jakub Hadvig Date: Wed, 22 Jul 2026 13:47:56 +0200 Subject: [PATCH 2/6] Fix UpdatePlanTab and AnalysisResultView bugs from PM review - Sort version dropdown by semver using compareSemVer (V3#1) - Filter expandable panels to show only the selected version's proposal instead of all active runs (V3#2) - Suppress stale decision badge during re-analysis by checking phase !== Analyzing (V3#3) - Track user-collapsed panels in a ref so the auto-expand useEffect respects manual collapses (V1#7, V2#4) - Fix pod logs link: append /logs to URL path, use react-router Link instead of raw tag (V2#1) - Use shared getDecisionDisplay() in both UpdatePlanTab and AnalysisResultView, replacing inline toUpperCase() (V1#10) - Replace bare "No agentic runs available" with a Card explaining how update plans are created (V1#8) - Remove DecisionActions rendering for TP (V1#6) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../update-plan/AnalysisResultView.tsx | 16 +- src/components/update-plan/UpdatePlanTab.tsx | 306 +++++++++--------- 2 files changed, 164 insertions(+), 158 deletions(-) diff --git a/src/components/update-plan/AnalysisResultView.tsx b/src/components/update-plan/AnalysisResultView.tsx index e88814f..7f1ae4d 100644 --- a/src/components/update-plan/AnalysisResultView.tsx +++ b/src/components/update-plan/AnalysisResultView.tsx @@ -24,6 +24,7 @@ import { getFindings, sortFindings, getOlmOperatorStatus, + getDecisionDisplay, SEVERITY_LABELS, } from '../../models/agenticrun'; import { I18N_NAMESPACE } from '../../utils/constants'; @@ -32,13 +33,6 @@ type AnalysisResultViewProps = { analysisData: AnalysisData; }; -const decisionColors: Record = { - recommend: 'green', - caution: 'orange', - block: 'red', - escalate: 'purple', -}; - const checkStatusIcon = (status: string) => { switch (status) { case 'pass': @@ -101,11 +95,11 @@ const AnalysisResultView: React.FC = ({ analysisData }) {t('AI Assessment')} @@ -249,11 +243,11 @@ const AnalysisResultView: React.FC = ({ analysisData }) {t('AI Assessment')} {decision && ( )} diff --git a/src/components/update-plan/UpdatePlanTab.tsx b/src/components/update-plan/UpdatePlanTab.tsx index f4c7625..dd36a79 100644 --- a/src/components/update-plan/UpdatePlanTab.tsx +++ b/src/components/update-plan/UpdatePlanTab.tsx @@ -20,6 +20,7 @@ import { import { RedoIcon, SearchIcon } from '@patternfly/react-icons'; import { k8sPatch } from '@openshift-console/dynamic-plugin-sdk'; import { ClusterVersion } from '../../models/clusterversion'; +import { Link } from 'react-router'; import { LightspeedAgenticRun, LightspeedAgenticRunModel, @@ -27,16 +28,18 @@ import { ACTIVE_AGENTIC_RUN_PHASES, derivePhase, getAnalysisDataFromResult, + getDecisionDisplay, getPhaseDisplay, } from '../../models/agenticrun'; import { I18N_NAMESPACE, LABELS } from '../../utils/constants'; -import { unsanitizeVersion } from '../../utils/version'; +import { compareSemVer, unsanitizeVersion } from '../../utils/version'; import { useApprovalActions } from '../../hooks/useApprovalActions'; import { useAgenticRunApprovals, useAnalysisResults } from '../../hooks/useAgenticRuns'; import PhaseLabel from '../shared/PhaseLabel'; import PlanHeader from './PlanHeader'; import AnalysisResultView from './AnalysisResultView'; -import DecisionActions from './DecisionActions'; +// TODO: Re-enable DecisionActions post-TP +// import DecisionActions from './DecisionActions'; type ReanalyseButtonProps = { agenticRun: LightspeedAgenticRun; @@ -107,10 +110,11 @@ type UpdatePlanTabProps = { agenticRuns: LightspeedAgenticRun[]; }; -const UpdatePlanTab: React.FC = ({ clusterVersion, agenticRuns }) => { +const UpdatePlanTab: React.FC = ({ agenticRuns }) => { const { t } = useTranslation(I18N_NAMESPACE); const [selectedName, setSelectedName] = React.useState(''); const [expandedPanels, setExpandedPanels] = React.useState>(new Set()); + const userCollapsedRef = React.useRef>(new Set()); const [submittedNames, setSubmittedNames] = React.useState>(new Set()); const [approvalsRaw] = useAgenticRunApprovals(); const approvals = approvalsRaw ?? []; @@ -157,13 +161,14 @@ const UpdatePlanTab: React.FC = ({ clusterVersion, agenticRu if (stillPending.size < submittedNames.size) setSubmittedNames(stillPending); }, [agenticRuns, submittedNames]); - // Auto-expand newly analysed runs + // Auto-expand newly active runs unless the user manually collapsed them React.useEffect(() => { if (activeRuns.length > 0) { setExpandedPanels((prev) => { const next = new Set(prev); activeRuns.forEach((p) => { - if (p.metadata?.name) next.add(p.metadata.name); + const name = p.metadata?.name; + if (name && !userCollapsedRef.current.has(name)) next.add(name); }); return next; }); @@ -194,15 +199,27 @@ const UpdatePlanTab: React.FC = ({ clusterVersion, agenticRu const next = new Set(prev); if (next.has(name)) { next.delete(name); + userCollapsedRef.current.add(name); } else { next.add(name); + userCollapsedRef.current.delete(name); } return next; }); }, []); if (agenticRuns.length === 0) { - return {t('No agentic runs available.')}; + return ( + + + + {t( + 'No update plans available. Update plans are created automatically when the cluster-version-operator detects available update paths.', + )} + + + + ); } const showAnalyseButton = selectedPhase === 'Pending'; @@ -221,23 +238,33 @@ const UpdatePlanTab: React.FC = ({ clusterVersion, agenticRu onChange={(_event, value) => setSelectedName(value)} aria-label={t('Select agentic run')} > - {agenticRuns.map((p) => { - const rawTarget = p.metadata?.labels?.[LABELS.targetVersion] ?? ''; - const target = rawTarget - ? unsanitizeVersion(rawTarget) - : (p.metadata?.name ?? ''); - const updateType = p.metadata?.labels?.[LABELS.updateType] ?? ''; - const pPhase = derivePhase(p); - const suffix = - pPhase !== 'Pending' ? ` (${getPhaseDisplay(pPhase).label})` : ''; - return ( - - ); - })} + {[...agenticRuns] + .sort((a, b) => { + const vA = unsanitizeVersion( + a.metadata?.labels?.[LABELS.targetVersion] ?? '', + ); + const vB = unsanitizeVersion( + b.metadata?.labels?.[LABELS.targetVersion] ?? '', + ); + return compareSemVer(vA, vB); + }) + .map((p) => { + const rawTarget = p.metadata?.labels?.[LABELS.targetVersion] ?? ''; + const target = rawTarget + ? unsanitizeVersion(rawTarget) + : (p.metadata?.name ?? ''); + const updateType = p.metadata?.labels?.[LABELS.updateType] ?? ''; + const pPhase = derivePhase(p); + const suffix = + pPhase !== 'Pending' ? ` (${getPhaseDisplay(pPhase).label})` : ''; + return ( + + ); + })} @@ -272,128 +299,117 @@ const UpdatePlanTab: React.FC = ({ clusterVersion, agenticRu - {/* Analysed runs as expandable panels */} - {activeRuns.map((agenticRun) => { - const name = agenticRun.metadata?.name ?? ''; - const rawTarget = agenticRun.metadata?.labels?.[LABELS.targetVersion] ?? ''; - const target = rawTarget ? unsanitizeVersion(rawTarget) : name; - const pPhase = derivePhase(agenticRun); - const phaseDisplay = getPhaseDisplay(pPhase); + {/* Selected run's expandable panel */} + {activeRuns + .filter((r) => r.metadata?.name === selectedName) + .map((agenticRun) => { + const name = agenticRun.metadata?.name ?? ''; + const rawTarget = agenticRun.metadata?.labels?.[LABELS.targetVersion] ?? ''; + const target = rawTarget ? unsanitizeVersion(rawTarget) : name; + const pPhase = derivePhase(agenticRun); + const phaseDisplay = getPhaseDisplay(pPhase); - const resultRef = (agenticRun.status?.steps?.analysis?.results?.[0] as { name?: string }) - ?.name; - const result = resultRef - ? analysisResults.find( - (r: LightspeedAnalysisResult) => - r.metadata?.name === resultRef && - r.metadata?.namespace === agenticRun.metadata?.namespace, - ) - : undefined; - const resultData = getAnalysisDataFromResult(result); - const readinessSummary = resultData.components.find( - (c) => c.type === 'ota_readiness_summary', - ); - const decision = - ((readinessSummary as Record)?.decision as string | undefined) ?? - (resultData.analysisData?.decision as string | undefined); + const resultRef = (agenticRun.status?.steps?.analysis?.results?.[0] as { name?: string }) + ?.name; + const result = resultRef + ? analysisResults.find( + (r: LightspeedAnalysisResult) => + r.metadata?.name === resultRef && + r.metadata?.namespace === agenticRun.metadata?.namespace, + ) + : undefined; + const resultData = getAnalysisDataFromResult(result); + const readinessSummary = resultData.components.find( + (c) => c.type === 'ota_readiness_summary', + ); + const decision = + ((readinessSummary as Record)?.decision as string | undefined) ?? + (resultData.analysisData?.decision as string | undefined); + const decisionDisplay = decision ? getDecisionDisplay(decision) : undefined; - return ( - - - - {t('Update to {{version}}', { version: target })} - - - - - {decision && ( + return ( + + + + {t('Update to {{version}}', { version: target })} + - - )} - - - - - } - isExpanded={expandedPanels.has(name)} - onToggle={() => togglePanel(name)} - isIndented - > - - - - - {pPhase === 'Analyzing' || (pPhase === 'Pending' && submittedNames.has(name)) ? ( + {decisionDisplay && pPhase !== 'Analyzing' && ( + + + + )} + + + + + } + isExpanded={expandedPanels.has(name)} + onToggle={() => togglePanel(name)} + isIndented + > + - - - - - - - - - - - {agenticRun.status?.steps?.analysis?.sandbox?.claimName - ? t('AI agent is analysing cluster readiness...') - : t('Starting analysis — waiting for agent sandbox...')} - - - {agenticRun.status?.steps?.analysis?.sandbox?.claimName && ( + + + {pPhase === 'Analyzing' || (pPhase === 'Pending' && submittedNames.has(name)) ? ( + + + + + + + + + - - {t('Sandbox: {{name}}', { - name: agenticRun.status.steps.analysis.sandbox.claimName, - })} - {' — '} - - {t('View pod logs')} - - + + {agenticRun.status?.steps?.analysis?.sandbox?.claimName + ? t('AI agent is analysing cluster readiness...') + : t('Starting analysis — waiting for agent sandbox...')} + - )} - - - - - - - ) : pPhase === 'Failed' ? ( - - - {(agenticRun.status?.conditions as { type: string; message: string }[])?.find( - (c) => c.type === 'Analyzed', - )?.message ?? t('Unknown error')} - - - ) : ( - <> + {agenticRun.status?.steps?.analysis?.sandbox?.claimName && ( + + + {t('Sandbox: {{name}}', { + name: agenticRun.status.steps.analysis.sandbox.claimName, + })} + {' — '} + + {t('View pod logs')} + + + + )} + + + + + + + ) : pPhase === 'Failed' ? ( + + + {( + agenticRun.status?.conditions as { type: string; message: string }[] + )?.find((c) => c.type === 'Analyzed')?.message ?? t('Unknown error')} + + + ) : ( {resultData.components.length > 0 || resultData.analysisData ? ( @@ -401,16 +417,12 @@ const UpdatePlanTab: React.FC = ({ clusterVersion, agenticRu {t('Analysis result not yet available.')} )} - - - - - )} - - - - ); - })} + )} + + + + ); + })} ); }; From c8150d406fe119bcc66efc8a2536359e7dd59dc8 Mon Sep 17 00:00:00 2001 From: Jakub Hadvig Date: Wed, 22 Jul 2026 13:48:16 +0200 Subject: [PATCH 3/6] Fix page description, remove Update history tab, preserve tab state - Fix grammatical error in page description: "plan how this cluster version is newer" -> "plan your update to newer" (V3#4) - Remove Update history tab for Tech Preview (V1#2) - Remove conditional tab rendering ({activeTab === N && ...}) that caused UpdatePlanTab to unmount on tab switch, destroying local state like expandedPanels and submittedNames (V2#2, V2#4) - Update i18n locale files Co-Authored-By: Claude Opus 4.6 (1M context) --- ...plugin__cluster-update-console-plugin.json | 4 ++-- src/components/ClusterUpdatePage.tsx | 24 +++++-------------- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/locales/en/plugin__cluster-update-console-plugin.json b/locales/en/plugin__cluster-update-console-plugin.json index c553889..72f2d97 100644 --- a/locales/en/plugin__cluster-update-console-plugin.json +++ b/locales/en/plugin__cluster-update-console-plugin.json @@ -30,8 +30,8 @@ "Name": "Name", "No": "No", "No active update plans": "No active update plans", - "No agentic runs available.": "No agentic runs available.", "No update history available": "No update history available", + "No update plans available. Update plans are created automatically when the cluster-version-operator detects available update paths.": "No update plans available. Update plans are created automatically when the cluster-version-operator detects available update paths.", "OLM operators": "OLM operators", "OLM Operators": "OLM Operators", "Operator": "Operator", @@ -45,7 +45,7 @@ "Readiness checks": "Readiness checks", "Readiness Checks": "Readiness Checks", "Reject plan": "Reject plan", - "Review available versions, assess operator compatibility, and plan how this cluster version is newer OpenShift releases. Use Updates plan to prepare or start an update, Active update plans for in-flight work, and Update history for completed ones.": "Review available versions, assess operator compatibility, and plan how this cluster version is newer OpenShift releases. Use Updates plan to prepare or start an update, Active update plans for in-flight work, and Update history for completed ones.", + "Review available versions, assess operator compatibility, and plan your update to newer OpenShift releases. Use Updates plan to prepare or start an update and Active update plans for in-flight work.": "Review available versions, assess operator compatibility, and plan your update to newer OpenShift releases. Use Updates plan to prepare or start an update and Active update plans for in-flight work.", "Sandbox: {{name}}": "Sandbox: {{name}}", "Schedule for later": "Schedule for later", "Select agentic run": "Select agentic run", diff --git a/src/components/ClusterUpdatePage.tsx b/src/components/ClusterUpdatePage.tsx index 79c96a4..bc7fa14 100644 --- a/src/components/ClusterUpdatePage.tsx +++ b/src/components/ClusterUpdatePage.tsx @@ -23,7 +23,6 @@ import { LightspeedAgenticRun, derivePhase } from '../models/agenticrun'; import { ClusterVersion } from '../models/clusterversion'; import UpdatePlanTab from './update-plan/UpdatePlanTab'; import ActivePlansTab from './active-plans/ActivePlansTab'; -import UpdateHistoryTab from './update-history/UpdateHistoryTab'; import './ClusterUpdatePage.css'; export default function ClusterUpdatePage() { @@ -64,7 +63,7 @@ export default function ClusterUpdatePage() { {t( - 'Review available versions, assess operator compatibility, and plan how this cluster version is newer OpenShift releases. Use Updates plan to prepare or start an update, Active update plans for in-flight work, and Update history for completed ones.', + 'Review available versions, assess operator compatibility, and plan your update to newer OpenShift releases. Use Updates plan to prepare or start an update and Active update plans for in-flight work.', )} @@ -98,28 +97,17 @@ export default function ClusterUpdatePage() { {t('Updates plan')}}> - {activeTab === 0 && ( - - )} + {t('Active update plans')}}> - {activeTab === 1 && } - - - - {t('Update history')}}> - - - {activeTab === 2 && ( - - )} + From 896d8fc766d50a26681de88aa066f01bcf6087ef Mon Sep 17 00:00:00 2001 From: Jakub Hadvig Date: Wed, 22 Jul 2026 13:48:34 +0200 Subject: [PATCH 4/6] Gate nav item and route on AgenticRun CRD availability Add console.flag/model extension that creates an AGENTIC_RUN feature flag based on the AgenticRun CRD. Gate both the console.navigation/href and console.page/route extensions on this flag so the Cluster Update nav item only appears when the Lightspeed operator is installed (V1#1). Co-Authored-By: Claude Opus 4.6 (1M context) --- console-extensions.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/console-extensions.json b/console-extensions.json index 3005feb..fd31236 100644 --- a/console-extensions.json +++ b/console-extensions.json @@ -1,9 +1,23 @@ [ + { + "type": "console.flag/model", + "properties": { + "model": { + "group": "agentic.openshift.io", + "version": "v1alpha1", + "kind": "AgenticRun" + }, + "flag": "AGENTIC_RUN" + } + }, { "type": "console.page/route", "properties": { "path": "/administration/cluster-update", "component": { "$codeRef": "ClusterUpdatePage" } + }, + "flags": { + "required": ["AGENTIC_RUN"] } }, { @@ -14,6 +28,9 @@ "href": "/administration/cluster-update", "perspective": "admin", "section": "administration" + }, + "flags": { + "required": ["AGENTIC_RUN"] } } ] From 4a1e9a8053d13e5776bd6a3117203ef5e115c477 Mon Sep 17 00:00:00 2001 From: Jakub Hadvig Date: Wed, 22 Jul 2026 15:25:41 +0200 Subject: [PATCH 5/6] Use PatternFly EmptyState for empty update plans view Replace plain Card with PF EmptyState component using CubesIcon, matching the existing pattern in ActivePlansTab.tsx (V1#8). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/update-plan/UpdatePlanTab.tsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/update-plan/UpdatePlanTab.tsx b/src/components/update-plan/UpdatePlanTab.tsx index dd36a79..f37ae19 100644 --- a/src/components/update-plan/UpdatePlanTab.tsx +++ b/src/components/update-plan/UpdatePlanTab.tsx @@ -7,6 +7,8 @@ import { CardBody, CardTitle, Content, + EmptyState, + EmptyStateBody, ExpandableSection, Flex, FlexItem, @@ -17,7 +19,7 @@ import { Stack, StackItem, } from '@patternfly/react-core'; -import { RedoIcon, SearchIcon } from '@patternfly/react-icons'; +import { CubesIcon, RedoIcon, SearchIcon } from '@patternfly/react-icons'; import { k8sPatch } from '@openshift-console/dynamic-plugin-sdk'; import { ClusterVersion } from '../../models/clusterversion'; import { Link } from 'react-router'; @@ -210,15 +212,13 @@ const UpdatePlanTab: React.FC = ({ agenticRuns }) => { if (agenticRuns.length === 0) { return ( - - - - {t( - 'No update plans available. Update plans are created automatically when the cluster-version-operator detects available update paths.', - )} - - - + + + {t( + 'Update plans are created automatically when the cluster-version-operator detects available update paths.', + )} + + ); } From 879a161fab09a406c947139485b69709df492ece Mon Sep 17 00:00:00 2001 From: Jakub Hadvig Date: Fri, 24 Jul 2026 15:42:53 +0200 Subject: [PATCH 6/6] Update i18n keys for EmptyState component The PatternFly EmptyState splits titleText and body into separate i18n keys. Regenerated with yarn i18n. Co-Authored-By: Claude Opus 4.6 (1M context) --- locales/en/plugin__cluster-update-console-plugin.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/locales/en/plugin__cluster-update-console-plugin.json b/locales/en/plugin__cluster-update-console-plugin.json index 72f2d97..1642ce8 100644 --- a/locales/en/plugin__cluster-update-console-plugin.json +++ b/locales/en/plugin__cluster-update-console-plugin.json @@ -31,7 +31,7 @@ "No": "No", "No active update plans": "No active update plans", "No update history available": "No update history available", - "No update plans available. Update plans are created automatically when the cluster-version-operator detects available update paths.": "No update plans available. Update plans are created automatically when the cluster-version-operator detects available update paths.", + "No update plans available": "No update plans available", "OLM operators": "OLM operators", "OLM Operators": "OLM Operators", "Operator": "Operator", @@ -63,6 +63,7 @@ "Unknown error": "Unknown error", "Update": "Update", "Update history": "Update history", + "Update plans are created automatically when the cluster-version-operator detects available update paths.": "Update plans are created automatically when the cluster-version-operator detects available update paths.", "Update to {{version}}": "Update to {{version}}", "Update Type": "Update Type", "Updates plan": "Updates plan",