diff --git a/locales/en/plugin__lightspeed-console-plugin.json b/locales/en/plugin__lightspeed-console-plugin.json index f5e90018..6fbbb322 100644 --- a/locales/en/plugin__lightspeed-console-plugin.json +++ b/locales/en/plugin__lightspeed-console-plugin.json @@ -17,6 +17,8 @@ "Cancelled": "Cancelled", "Changes you made may not be saved.": "Changes you made may not be saved.", "Clear chat": "Clear chat", + "Cluster resource": "Cluster resource", + "Cluster resources": "Cluster resources", "Collapse": "Collapse", "Compressed in {{seconds}} seconds": "Compressed in {{seconds}} seconds", "Compressing history...": "Compressing history...", @@ -50,6 +52,7 @@ "Error querying OpenShift Lightspeed service": "Error querying OpenShift Lightspeed service", "Error submitting feedback": "Error submitting feedback", "Events": "Events", + "Evidence sources": "Evidence sources", "Expand": "Expand", "Expert guidance and clear answers": "Expert guidance and clear answers", "Explore deeper insights, engage in meaningful discussions, and unlock new possibilities with Red Hat OpenShift Lightspeed. Answers are provided by generative AI technology, please use appropriate caution when following recommendations.": "Explore deeper insights, engage in meaningful discussions, and unlock new possibilities with Red Hat OpenShift Lightspeed. Answers are provided by generative AI technology, please use appropriate caution when following recommendations.", @@ -79,6 +82,7 @@ "Large prompt": "Large prompt", "Leave": "Leave", "Loading MCP App...": "Loading MCP App...", + "Loading resource status": "Loading resource status", "Logs": "Logs", "MCP App Error": "MCP App Error", "MCP App: {{toolName}}": "MCP App: {{toolName}}", @@ -87,6 +91,8 @@ "Minimize": "Minimize", "Most recent {{lines}} lines": "Most recent {{lines}} lines", "Most recent {{numEvents}} events": "Most recent {{numEvents}} events", + "Next cluster resource": "Next cluster resource", + "Next source": "Next source", "No events": "No events", "No logs found": "No logs found", "No output returned": "No output returned", @@ -104,14 +110,21 @@ "Pod": "Pod", "Preview attachment": "Preview attachment", "Preview attachment - modified": "Preview attachment - modified", + "Previous cluster resource": "Previous cluster resource", + "Previous source": "Previous source", "Red Hat OpenShift Lightspeed": "Red Hat OpenShift Lightspeed", "Refresh": "Refresh", "Reject": "Reject", + "resource": "resource", + "resources": "resources", "Restore": "Restore", "Revert to original": "Revert to original", "Review required": "Review required", "Save": "Save", + "Showing {{shown}} of {{total}} cluster resources": "Showing {{shown}} of {{total}} cluster resources", "Silence": "Silence", + "source": "source", + "sources": "sources", "Status": "Status", "Stay": "Stay", "Structured content": "Structured content", diff --git a/src/components/GeneralPage.tsx b/src/components/GeneralPage.tsx index 62e9ae1d..dd60f4f0 100644 --- a/src/components/GeneralPage.tsx +++ b/src/components/GeneralPage.tsx @@ -3,7 +3,7 @@ import { defer } from 'lodash'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; -import { consoleFetchJSON } from '@openshift-console/dynamic-plugin-sdk'; +import { consoleFetchJSON, useK8sModels } from '@openshift-console/dynamic-plugin-sdk'; import { Alert, Badge, @@ -41,7 +41,10 @@ import { toOLSAttachment } from '../attachments'; import { getApiUrl } from '../config'; import { copyToClipboard } from '../clipboard'; import { ErrorType, getFetchErrorMessage } from '../error'; +import { K8sModelRef } from '../pageContext'; import { AuthStatus, getRequestInitWithAuthHeader, useAuth } from '../hooks/useAuth'; +import { useLinkedResourceMarkdown } from '../hooks/useLinkedResourceMarkdown'; +import { useMessageSources } from '../hooks/useMessageSources'; import { useBoolean } from '../hooks/useBoolean'; import { useFirstTimeUser } from '../hooks/useFirstTimeUser'; import { useIsDarkTheme } from '../hooks/useIsDarkTheme'; @@ -55,7 +58,7 @@ import { userFeedbackSetText, } from '../redux-actions'; import { State } from '../redux-reducers'; -import { Attachment, ChatEntry, ReferencedDoc, Tool } from '../types'; +import { Attachment, ChatEntry, Tool } from '../types'; import AttachmentLabel from './AttachmentLabel'; import AttachmentsSizeAlert from './AttachmentsSizeAlert'; import ImportAction from './ImportAction'; @@ -84,15 +87,6 @@ const ExternalLink: React.FC = ({ children, href }) => ( ); -const isURL = (s: string): boolean => { - try { - const url = new URL(s); - return !!(url.protocol && url.host); - } catch { - return false; - } -}; - const ImportCodeBlockAction: React.FC = () => { const containerRef = React.useRef(null); const [value, setValue] = React.useState(''); @@ -124,307 +118,325 @@ const THUMBS_UP = 1; type ChatHistoryEntryProps = { conversationID: string; entryIndex: number; + k8sModels: Record; + modelsLoaded: boolean; }; -const ChatHistoryEntry = React.memo(({ conversationID, entryIndex }: ChatHistoryEntryProps) => { - const { t } = useTranslation('plugin__lightspeed-console-plugin'); +const ChatHistoryEntry = React.memo( + ({ conversationID, entryIndex, k8sModels, modelsLoaded }: ChatHistoryEntryProps) => { + const { t } = useTranslation('plugin__lightspeed-console-plugin'); - const dispatch = useDispatch(); + const dispatch = useDispatch(); - const [feedbackError, setFeedbackError] = React.useState(); - const [feedbackSubmitted, setFeedbackSubmitted] = React.useState(false); - const [isContextExpanded, toggleContextExpanded] = useBoolean(false); + const [feedbackError, setFeedbackError] = React.useState(); + const [feedbackSubmitted, setFeedbackSubmitted] = React.useState(false); + const [isContextExpanded, toggleContextExpanded] = useBoolean(false); - const entryMap = useSelector((s: State) => s.plugins?.ols?.getIn(['chatHistory', entryIndex])); - const entry = entryMap.toJS() as ChatEntry; + const entryMap = useSelector((s: State) => s.plugins?.ols?.getIn(['chatHistory', entryIndex])); + const entry = entryMap?.toJS() as ChatEntry; + const toolsMap = useSelector((s: State) => + s.plugins?.ols?.getIn(['chatHistory', entryIndex, 'tools']), + ) as ImmutableMap> | undefined; - const attachments: ImmutableMap = useSelector((s: State) => - s.plugins?.ols?.getIn(['chatHistory', entryIndex - 1, 'attachments']), - ); - const isFeedbackOpen: boolean = useSelector((s: State) => - s.plugins?.ols?.getIn(['chatHistory', entryIndex, 'userFeedback', 'isOpen']), - ); - const query: string = useSelector((s: State) => - s.plugins?.ols?.getIn(['chatHistory', entryIndex - 1, 'text']), - ); - const response: string = useSelector((s: State) => - s.plugins?.ols?.getIn(['chatHistory', entryIndex, 'text']), - ); + const aiTools = React.useMemo((): Record | undefined => { + if (!toolsMap || toolsMap.size === 0) { + return undefined; + } + return toolsMap.toJS() as Record; + }, [toolsMap]); - const isUserFeedbackEnabled = useSelector((s: State) => - s.plugins?.ols?.get('isUserFeedbackEnabled'), - ); - const sentiment: number = useSelector((s: State) => - s.plugins?.ols?.getIn(['chatHistory', entryIndex, 'userFeedback', 'sentiment']), - ); - const feedbackText: string = useSelector((s: State) => - s.plugins?.ols?.getIn(['chatHistory', entryIndex, 'userFeedback', 'text']), - ); + const attachments: ImmutableMap = useSelector((s: State) => + s.plugins?.ols?.getIn(['chatHistory', entryIndex - 1, 'attachments']), + ); + const isFeedbackOpen: boolean = useSelector((s: State) => + s.plugins?.ols?.getIn(['chatHistory', entryIndex, 'userFeedback', 'isOpen']), + ); + const query: string = useSelector((s: State) => + s.plugins?.ols?.getIn(['chatHistory', entryIndex - 1, 'text']), + ); + const response: string = useSelector((s: State) => + s.plugins?.ols?.getIn(['chatHistory', entryIndex, 'text']), + ); - const [isDarkTheme] = useIsDarkTheme(); - - const onThumbsDown = React.useCallback(() => { - dispatch(userFeedbackOpen(entryIndex)); - dispatch(userFeedbackSetSentiment(entryIndex, THUMBS_DOWN)); - setFeedbackSubmitted(false); - }, [dispatch, entryIndex]); - - const onThumbsUp = React.useCallback(() => { - dispatch(userFeedbackOpen(entryIndex)); - dispatch(userFeedbackSetSentiment(entryIndex, THUMBS_UP)); - setFeedbackSubmitted(false); - }, [dispatch, entryIndex]); - - const onFeedbackClose = React.useCallback(() => { - dispatch(userFeedbackClose(entryIndex)); - }, [dispatch, entryIndex]); - - const onFeedbackTextChange = React.useCallback( - (_event: React.ChangeEvent, value: string) => { - dispatch(userFeedbackSetText(entryIndex, value)); - }, - [dispatch, entryIndex], - ); + const isUserFeedbackEnabled = useSelector((s: State) => + s.plugins?.ols?.get('isUserFeedbackEnabled'), + ); + const sentiment: number = useSelector((s: State) => + s.plugins?.ols?.getIn(['chatHistory', entryIndex, 'userFeedback', 'sentiment']), + ); + const feedbackText: string = useSelector((s: State) => + s.plugins?.ols?.getIn(['chatHistory', entryIndex, 'userFeedback', 'text']), + ); - const onFeedbackSubmit = React.useCallback(() => { - const userQuestion = attachments - ? `${query}\n---\nThe attachments that were sent with the prompt are shown below.\n${JSON.stringify(attachments.valueSeq().map(toOLSAttachment), null, 2)}` - : query; - - /* eslint-disable camelcase */ - const requestJSON = { - conversation_id: conversationID, - llm_response: response, - sentiment, - user_feedback: feedbackText ?? '', - user_question: userQuestion, - }; - /* eslint-enable camelcase */ - - consoleFetchJSON - .post(USER_FEEDBACK_ENDPOINT, requestJSON, getRequestInitWithAuthHeader(), REQUEST_TIMEOUT) - .then(() => { - setFeedbackSubmitted(true); - }) - .catch((err) => { - setFeedbackError(getFetchErrorMessage(err, t)); - setFeedbackSubmitted(false); - }); - }, [conversationID, query, attachments, response, sentiment, feedbackText, t]); + const [isDarkTheme] = useIsDarkTheme(); - if (entry.who === 'user' && entry.hidden) { - return null; - } + const isAiEntry = entry?.who === 'ai'; + const isAiResponseReady = isAiEntry && !entry.isStreaming && !entry.error; + const messageSources = useMessageSources({ + enabled: isAiResponseReady, + references: isAiEntry ? entry.references : undefined, + }); + const linkedResourceMarkdown = useLinkedResourceMarkdown({ + enabled: isAiResponseReady && modelsLoaded, + k8sModels, + responseText: isAiEntry ? entry.text : undefined, + tools: aiTools, + }); + const displayContent = isAiEntry ? linkedResourceMarkdown.content : entry?.text; + + const onThumbsDown = React.useCallback(() => { + dispatch(userFeedbackOpen(entryIndex)); + dispatch(userFeedbackSetSentiment(entryIndex, THUMBS_DOWN)); + setFeedbackSubmitted(false); + }, [dispatch, entryIndex]); + + const onThumbsUp = React.useCallback(() => { + dispatch(userFeedbackOpen(entryIndex)); + dispatch(userFeedbackSetSentiment(entryIndex, THUMBS_UP)); + setFeedbackSubmitted(false); + }, [dispatch, entryIndex]); + + const onFeedbackClose = React.useCallback(() => { + dispatch(userFeedbackClose(entryIndex)); + }, [dispatch, entryIndex]); + + const onFeedbackTextChange = React.useCallback( + (_event: React.ChangeEvent, value: string) => { + dispatch(userFeedbackSetText(entryIndex, value)); + }, + [dispatch, entryIndex], + ); - if (entry.who === 'ai') { - const thumbsUpTooltip = t('Good response'); - const thumbsDownTooltip = t('Bad response'); - const actions = entry.error - ? undefined - : { - copy: { onClick: () => copyToClipboard(entry.text) }, - ...(isUserFeedbackEnabled && { - positive: { - clickedTooltipContent: thumbsUpTooltip, - onClick: onThumbsUp, - tooltipContent: thumbsUpTooltip, - }, - negative: { - clickedTooltipContent: thumbsDownTooltip, - onClick: onThumbsDown, - tooltipContent: thumbsDownTooltip, - }, - }), - }; - - let sources: SourcesCardProps | undefined; - if (Array.isArray(entry.references)) { - const references: ReferencedDoc[] = entry.references.filter( - (r) => - r && typeof r.doc_title === 'string' && typeof r.doc_url === 'string' && isURL(r.doc_url), - ); - if (references.length > 0) { - sources = { - sources: references.map((r) => ({ - isExternal: true, - title: r.doc_title, - link: r.doc_url, - })), - }; - } + const onFeedbackSubmit = React.useCallback(() => { + const userQuestion = attachments + ? `${query}\n---\nThe attachments that were sent with the prompt are shown below.\n${JSON.stringify(attachments.valueSeq().map(toOLSAttachment), null, 2)}` + : query; + + /* eslint-disable camelcase */ + const requestJSON = { + conversation_id: conversationID, + llm_response: response, + sentiment, + user_feedback: feedbackText ?? '', + user_question: userQuestion, + }; + /* eslint-enable camelcase */ + + consoleFetchJSON + .post(USER_FEEDBACK_ENDPOINT, requestJSON, getRequestInitWithAuthHeader(), REQUEST_TIMEOUT) + .then(() => { + setFeedbackSubmitted(true); + }) + .catch((err) => { + setFeedbackError(getFetchErrorMessage(err, t)); + setFeedbackSubmitted(false); + }); + }, [conversationID, query, attachments, response, sentiment, feedbackText, t]); + + if (!entry) { + return null; } - const pendingApprovalTools = entry.tools - ? Object.entries(entry.tools as unknown as { [key: string]: Tool }).filter( - ([, tool]) => tool.isUserApproval && !tool.isApproved && !tool.isDenied, - ) - : []; - - const historyCompressedAlert = ( - } - isInline - isPlain - title={t('History compressed')} - variant="success" - /> - ); + if (entry.who === 'user' && entry.hidden) { + return null; + } - return ( - , - isExpandable: true, - }} - content={entry.text} - data-test="ols-plugin__chat-entry-ai" - extraContent={{ - afterMainContent: ( - <> - {entry.error && ( - - {entry.error.moreInfo ? entry.error.moreInfo : entry.error.message} - - )} - {entry.historyCompression?.status === 'compressing' && !entry.isCancelled && ( - } - isInline - isPlain - title={t('Compressing history...')} - variant="info" - /> - )} - {entry.historyCompression?.status === 'done' && - (entry.historyCompression.durationMs === undefined ? ( - historyCompressedAlert - ) : ( - copyToClipboard(entry.text) }, + ...(isUserFeedbackEnabled && { + positive: { + clickedTooltipContent: thumbsUpTooltip, + onClick: onThumbsUp, + tooltipContent: thumbsUpTooltip, + }, + negative: { + clickedTooltipContent: thumbsDownTooltip, + onClick: onThumbsDown, + tooltipContent: thumbsDownTooltip, + }, + }), + }; + + const sources: SourcesCardProps | undefined = messageSources.messageSources; + + const pendingApprovalTools = aiTools + ? Object.entries(aiTools).filter( + ([, tool]) => tool.isUserApproval && !tool.isApproved && !tool.isDenied, + ) + : []; + + const historyCompressedAlert = ( + } + isInline + isPlain + title={t('History compressed')} + variant="success" + /> + ); + + return ( + , + isExpandable: true, + }} + content={displayContent} + data-test="ols-plugin__chat-entry-ai" + extraContent={{ + afterMainContent: ( + <> + {entry.error && ( + - {historyCompressedAlert} - + {entry.error.moreInfo ? entry.error.moreInfo : entry.error.message} + + )} + {entry.historyCompression?.status === 'compressing' && !entry.isCancelled && ( + } + isInline + isPlain + title={t('Compressing history...')} + variant="info" + /> + )} + {entry.historyCompression?.status === 'done' && + (entry.historyCompression.durationMs === undefined ? ( + historyCompressedAlert + ) : ( + + {historyCompressedAlert} + + ))} + {entry.isTruncated && ( + + {t('Conversation history has been truncated to fit within context window.')} + + )} + {entry.isCancelled && ( + + )} + {pendingApprovalTools.map(([toolID, tool]) => ( + ))} - {entry.isTruncated && ( - - {t('Conversation history has been truncated to fit within context window.')} - - )} - {entry.isCancelled && ( - - )} - {pendingApprovalTools.map(([toolID, tool]) => ( - - ))} - {entry.tools && } - - ), - endContent: feedbackError ? ( - - {feedbackError.moreInfo ? feedbackError.moreInfo : feedbackError.message} - - ) : undefined, - }} - hasRoundAvatar={false} - isCompact - isLoading={!entry.text && !entry.isCancelled && !entry.error} - name="OpenShift Lightspeed" - role="bot" - sources={sources} - timestamp=" " - userFeedbackComplete={ - isFeedbackOpen && feedbackSubmitted ? { onClose: onFeedbackClose } : undefined - } - userFeedbackForm={ - isFeedbackOpen && !feedbackSubmitted && sentiment !== undefined - ? { - className: 'ols-plugin__feedback', - hasTextArea: true, - headingLevel: 'h6', - onClose: onFeedbackClose, - onSubmit: onFeedbackSubmit, - onTextAreaChange: onFeedbackTextChange, - submitWord: t('Submit'), - textAreaProps: { value: feedbackText ?? '' }, - title: t( - "Do not include personal information or other sensitive information in your feedback. Feedback may be used to improve Red Hat's products or services.", - ), - } - : undefined - } - /> - ); - } + {aiTools && } + + ), + endContent: feedbackError ? ( + + {feedbackError.moreInfo ? feedbackError.moreInfo : feedbackError.message} + + ) : undefined, + }} + hasRoundAvatar={false} + isCompact + isLoading={!entry.text && !entry.isCancelled && !entry.error} + name="OpenShift Lightspeed" + reactMarkdownProps={linkedResourceMarkdown.reactMarkdownProps} + role="bot" + sources={sources} + timestamp=" " + userFeedbackComplete={ + isFeedbackOpen && feedbackSubmitted ? { onClose: onFeedbackClose } : undefined + } + userFeedbackForm={ + isFeedbackOpen && !feedbackSubmitted && sentiment !== undefined + ? { + className: 'ols-plugin__feedback', + hasTextArea: true, + headingLevel: 'h6', + onClose: onFeedbackClose, + onSubmit: onFeedbackSubmit, + onTextAreaChange: onFeedbackTextChange, + submitWord: t('Submit'), + textAreaProps: { value: feedbackText ?? '' }, + title: t( + "Do not include personal information or other sensitive information in your feedback. Feedback may be used to improve Red Hat's products or services.", + ), + } + : undefined + } + /> + ); + } - if (entry.who === 'user') { - return ( - -
{entry.text}
- {entry.attachments && Object.keys(entry.attachments).length > 0 && ( - - {t('Context')} {Object.keys(entry.attachments).length} - - } - > - {Object.keys(entry.attachments).map((key: string) => { - const attachment: Attachment = entry.attachments[key]; - return ; - })} - - )} - - ), - }} - hasRoundAvatar={false} - isCompact - name="You" - role="user" - timestamp=" " - /> - ); - } + if (entry.who === 'user') { + return ( + +
{entry.text}
+ {entry.attachments && Object.keys(entry.attachments).length > 0 && ( + + {t('Context')} {Object.keys(entry.attachments).length} + + } + > + {Object.keys(entry.attachments).map((key: string) => { + const attachment: Attachment = entry.attachments[key]; + return ; + })} + + )} + + ), + }} + hasRoundAvatar={false} + isCompact + name="You" + role="user" + timestamp=" " + /> + ); + } - return null; -}); + return null; + }, +); ChatHistoryEntry.displayName = 'ChatHistoryEntry'; type AuthAlertProps = { @@ -494,6 +506,9 @@ const GeneralPage: React.FC = ({ const conversationID: string = useSelector((s: State) => s.plugins?.ols?.get('conversationID')); + const [k8sModels, modelsInFlight] = useK8sModels(); + const modelsLoaded = modelsInFlight === false; + const [authStatus] = useAuth(); const [isFirstTimeUser] = useFirstTimeUser(); @@ -638,7 +653,9 @@ const GeneralPage: React.FC = ({ )) .toArray()} diff --git a/src/components/InlineLinkedResource.tsx b/src/components/InlineLinkedResource.tsx new file mode 100644 index 00000000..ee64669c --- /dev/null +++ b/src/components/InlineLinkedResource.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import { Button } from '@patternfly/react-core'; + +import LinkedResourceStatus from './LinkedResourceStatus'; +import ResourceIcon from './ResourceIcon'; +import { buildResourceConsolePath, getResourceIconKind, K8sModelRef } from '../pageContext'; +import { shouldShowLinkedResourceStatus } from '../linkedResourceWatch'; +import { ResourceRef } from '../resourceRefs'; + +import './linked-resources.css'; + +type InlineLinkedResourceProps = { + children: React.ReactNode; + k8sModels: Record; + navigate: (path: string) => void; + resourceRef: ResourceRef; +}; + +const InlineLinkedResource: React.FC = ({ + children, + k8sModels, + navigate, + resourceRef, +}) => { + const path = buildResourceConsolePath(resourceRef, k8sModels); + const iconKind = getResourceIconKind(resourceRef, k8sModels); + + return ( + + + {shouldShowLinkedResourceStatus(resourceRef, k8sModels) && ( + + )} + + ); +}; + +export default InlineLinkedResource; diff --git a/src/components/LinkedResourceStatus.tsx b/src/components/LinkedResourceStatus.tsx new file mode 100644 index 00000000..76a14535 --- /dev/null +++ b/src/components/LinkedResourceStatus.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + K8sResourceKind, + StatusComponent, + useK8sWatchResource, +} from '@openshift-console/dynamic-plugin-sdk'; +import { Badge, Spinner } from '@patternfly/react-core'; + +import { getLinkedResourceStatusDisplay } from '../linkedResourceStatusDisplay'; +import { buildResourceWatchProps, matchesResourceRef } from '../linkedResourceWatch'; +import { getModelKindName, K8sModelRef, resolveRefModelKeyOrKind } from '../pageContext'; +import { ResourceRef } from '../resourceRefs'; +import { getResourceStatusSummaryForRef, isInformativeStatusLabel } from '../resourceStatus'; + +type LinkedResourceStatusProps = { + k8sModels: Record; + resourceRef: ResourceRef; +}; + +const LinkedResourceStatus: React.FC = ({ k8sModels, resourceRef }) => { + const { t } = useTranslation('plugin__lightspeed-console-plugin'); + const watchProps = buildResourceWatchProps(resourceRef, k8sModels); + + const [resource, loaded, loadError] = useK8sWatchResource(watchProps); + + const hasMatchingResource = matchesResourceRef(resource, resourceRef, k8sModels); + + if (!watchProps) { + return null; + } + + if (loadError || (loaded && !hasMatchingResource)) { + return null; + } + + if (!loaded) { + return ; + } + + const modelKey = resolveRefModelKeyOrKind(resourceRef, k8sModels); + const kindName = getModelKindName(modelKey, k8sModels); + const status = getResourceStatusSummaryForRef(modelKey, k8sModels, resource); + if (!isInformativeStatusLabel(status.label)) { + return null; + } + + const display = getLinkedResourceStatusDisplay(status, kindName, modelKey); + if (display.mode === 'icon') { + return ( + + + + ); + } + + return ( + + {status.label} + + ); +}; + +export default LinkedResourceStatus; diff --git a/src/components/ResourceIcon.tsx b/src/components/ResourceIcon.tsx index acd2ef6c..b579343f 100644 --- a/src/components/ResourceIcon.tsx +++ b/src/components/ResourceIcon.tsx @@ -2,11 +2,12 @@ import * as React from 'react'; import { ResourceIcon as SDKResourceIcon } from '@openshift-console/dynamic-plugin-sdk'; type Props = { + className?: string; kind: string; }; -const ResourceIcon: React.FC = ({ kind }) => ( - +const ResourceIcon: React.FC = ({ className, kind }) => ( + ); export default ResourceIcon; diff --git a/src/components/linked-resources.css b/src/components/linked-resources.css new file mode 100644 index 00000000..bf15124d --- /dev/null +++ b/src/components/linked-resources.css @@ -0,0 +1,42 @@ +.ols-plugin__inline-linked-resource { + align-items: center; + display: inline-flex; + white-space: normal; +} + +.ols-plugin__inline-linked-resource-link { + font-weight: inherit; + padding: 0; + vertical-align: baseline; +} + +.ols-plugin__inline-linked-resource-content { + align-items: center; + display: inline-flex; + gap: var(--pf-t--global--spacer--xs); +} + +.ols-plugin__inline-linked-resource-kind-icon { + margin-inline-end: 0; +} + +.ols-plugin__inline-linked-resource-name { + display: inline; +} + +.ols-plugin__inline-linked-resource > * + * { + margin-inline-start: var(--pf-t--global--spacer--xs); + vertical-align: middle; +} + +.ols-plugin__inline-linked-resource .ols-plugin__linked-resource-status-icon { + display: inline-flex; +} + +.ols-plugin__linked-resource-status { + font-size: var(--pf-t--global--font--size--sm); + max-inline-size: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/consoleNavigation.ts b/src/consoleNavigation.ts new file mode 100644 index 00000000..15811657 --- /dev/null +++ b/src/consoleNavigation.ts @@ -0,0 +1,19 @@ +/** + * Navigate the host OpenShift console without assuming a specific react-router mount. + * Works from the Lightspeed modal launched via useModal. + */ +export const navigateToConsolePath = (path: string): void => { + if (!path || path === '#') { + return; + } + + const url = path.startsWith('/') ? path : `/${path}`; + + if (window.history?.pushState) { + window.history.pushState(window.history.state, '', url); + window.dispatchEvent(new PopStateEvent('popstate')); + return; + } + + window.location.assign(url); +}; diff --git a/src/crStatus.ts b/src/crStatus.ts new file mode 100644 index 00000000..9de1c147 --- /dev/null +++ b/src/crStatus.ts @@ -0,0 +1,214 @@ +import { K8sResourceKind } from '@openshift-console/dynamic-plugin-sdk'; + +import type { StatusSummary } from './resourceStatus'; + +type GenericCondition = { + lastTransitionTime?: string; + message?: string; + phase?: string; + reason?: string; + status?: string; + type?: string; +}; + +const SUCCESS_PHASES = new Set([ + 'Active', + 'Available', + 'Bound', + 'Complete', + 'Completed', + 'Healthy', + 'Installed', + 'Online', + 'Ready', + 'Running', + 'Succeeded', + 'True', +]); + +const WARNING_PHASES = new Set([ + 'InstallReady', + 'Installing', + 'NeedsReinstall', + 'Pending', + 'Progressing', + 'Terminating', + 'Unknown', + 'Waiting', +]); + +const DANGER_PHASES = new Set([ + 'Degraded', + 'Error', + 'Expired', + 'Failed', + 'False', + 'Lost', + 'Released', + 'Unhealthy', +]); + +const DANGER_REASONS = new Set([ + 'ComponentUnhealthy', + 'RequirementsNotMet', + 'InstallComponentFailed', + 'Failed', +]); + +export const variantForPhase = (phase: string, reason?: string): StatusSummary['variant'] => { + if (SUCCESS_PHASES.has(phase)) { + return 'success'; + } + if (DANGER_PHASES.has(phase) || (reason && DANGER_REASONS.has(reason))) { + return 'danger'; + } + if (WARNING_PHASES.has(phase)) { + return 'warning'; + } + return 'info'; +}; + +const formatStatusLabel = (primary: string, secondary?: string): string => { + if (!secondary || secondary === primary) { + return primary; + } + return `${primary} (${secondary})`; +}; + +const isStandardKubeCondition = (condition: GenericCondition): boolean => + typeof condition.type === 'string' && typeof condition.status === 'string'; + +const isPhaseCondition = (condition: GenericCondition): boolean => + typeof condition.phase === 'string' && !condition.type; + +const conditionTimestamp = (condition: GenericCondition): number => { + const raw = condition.lastTransitionTime; + if (!raw) { + return 0; + } + const parsed = new Date(raw).getTime(); + return Number.isNaN(parsed) ? 0 : parsed; +}; + +const pickLatestCondition = (conditions: GenericCondition[]): GenericCondition | null => { + if (conditions.length === 0) { + return null; + } + + return [...conditions].sort( + (left, right) => conditionTimestamp(right) - conditionTimestamp(left), + )[0]; +}; + +const getTopLevelStatus = ( + status?: K8sResourceKind['status'], +): { message?: string; phase?: string; reason?: string } => { + if (!status || typeof status !== 'object') { + return {}; + } + + const record = status as Record; + const phase = typeof record.phase === 'string' ? record.phase : undefined; + const reason = typeof record.reason === 'string' ? record.reason : undefined; + const message = typeof record.message === 'string' ? record.message : undefined; + const state = typeof record.state === 'string' ? record.state : undefined; + + return { + message, + phase: phase ?? state, + reason, + }; +}; + +const variantForStandardCondition = (status?: string): StatusSummary['variant'] => { + if (status === 'True') { + return 'success'; + } + if (status === 'False') { + return 'danger'; + } + return 'warning'; +}; + +const getStandardConditionStatus = (conditions: GenericCondition[]): StatusSummary | null => { + const standard = conditions.filter(isStandardKubeCondition); + if (standard.length === 0) { + return null; + } + + const priority = ['Degraded', 'Failure', 'Ready', 'Available', 'Progressing']; + for (const type of priority) { + const condition = standard.find((entry) => entry.type === type); + if (!condition) { + continue; + } + const label = condition.reason || condition.type || type; + if (type === 'Degraded' || type === 'Failure') { + return { + label, + variant: condition.status === 'True' ? 'danger' : 'success', + }; + } + return { + label, + variant: variantForStandardCondition(condition.status), + }; + } + + const latest = pickLatestCondition(standard); + if (!latest) { + return null; + } + + return { + label: formatStatusLabel(latest.reason || latest.type || 'Unknown', latest.type), + variant: variantForStandardCondition(latest.status), + }; +}; + +const getPhaseConditionStatus = (conditions: GenericCondition[]): StatusSummary | null => { + const phaseConditions = conditions.filter(isPhaseCondition); + if (phaseConditions.length === 0) { + return null; + } + + const latest = pickLatestCondition(phaseConditions); + if (!latest?.phase) { + return null; + } + + return { + label: formatStatusLabel(latest.phase, latest.reason), + variant: variantForPhase(latest.phase, latest.reason), + }; +}; + +export const getGenericResourceStatus = (resource?: K8sResourceKind): StatusSummary => { + if (!resource?.status) { + return { label: '—', variant: 'info' }; + } + + const topLevel = getTopLevelStatus(resource.status); + if (topLevel.phase) { + return { + label: formatStatusLabel(topLevel.phase, topLevel.reason), + variant: variantForPhase(topLevel.phase, topLevel.reason), + }; + } + + const conditions = resource.status.conditions; + if (Array.isArray(conditions) && conditions.length > 0) { + const typedConditions = conditions as GenericCondition[]; + const fromStandard = getStandardConditionStatus(typedConditions); + if (fromStandard) { + return fromStandard; + } + + const fromPhase = getPhaseConditionStatus(typedConditions); + if (fromPhase) { + return fromPhase; + } + } + + return { label: 'Live', variant: 'info' }; +}; diff --git a/src/hooks/useConsoleNavigation.ts b/src/hooks/useConsoleNavigation.ts new file mode 100644 index 00000000..058ce10c --- /dev/null +++ b/src/hooks/useConsoleNavigation.ts @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { useNavigate } from 'react-router'; + +import { navigateToConsolePath } from '../consoleNavigation'; + +export const useConsoleNavigation = (): ((path: string) => void) => { + const navigate = useNavigate(); + + return React.useCallback( + (path: string) => { + if (!path || path === '#') { + return; + } + + try { + navigate(path); + } catch { + try { + navigateToConsolePath(path); + } catch { + // Fail closed: navigation is best-effort from the Lightspeed modal. + } + } + }, + [navigate], + ); +}; diff --git a/src/hooks/useLinkedResourceMarkdown.tsx b/src/hooks/useLinkedResourceMarkdown.tsx new file mode 100644 index 00000000..e6017aa8 --- /dev/null +++ b/src/hooks/useLinkedResourceMarkdown.tsx @@ -0,0 +1,90 @@ +import * as React from 'react'; +import type { MessageProps } from '@patternfly/chatbot'; + +import InlineLinkedResource from '../components/InlineLinkedResource'; +import { useConsoleNavigation } from '../hooks/useConsoleNavigation'; +import { getInlineLinkedResources } from '../linkedResources'; +import { injectResourceLinksInMarkdown } from '../linkedResourceText'; +import { buildResourceConsolePath, K8sModelRef } from '../pageContext'; +import { ResourceRef, resourceRefKey } from '../resourceRefs'; +import { Tool } from '../types'; + +type ReactMarkdownProps = NonNullable; + +type UseLinkedResourceMarkdownOptions = { + enabled: boolean; + k8sModels: Record; + responseText?: string; + tools?: Record; +}; + +type UseLinkedResourceMarkdownResult = { + content?: string; + reactMarkdownProps?: ReactMarkdownProps; +}; + +export const useLinkedResourceMarkdown = ({ + enabled, + k8sModels, + responseText, + tools, +}: UseLinkedResourceMarkdownOptions): UseLinkedResourceMarkdownResult => { + const navigate = useConsoleNavigation(); + + const linkedRefs = React.useMemo( + () => (enabled ? getInlineLinkedResources(tools, responseText, k8sModels) : []), + [enabled, k8sModels, responseText, tools], + ); + + const refsByPath = React.useMemo(() => { + const map = new Map(); + for (const ref of linkedRefs) { + const path = buildResourceConsolePath(ref, k8sModels); + if (path) { + map.set(path, ref); + } + } + return map; + }, [k8sModels, linkedRefs]); + + const content = React.useMemo(() => { + if (!enabled || !responseText) { + return responseText; + } + return injectResourceLinksInMarkdown(responseText, linkedRefs, k8sModels); + }, [enabled, k8sModels, linkedRefs, responseText]); + + const reactMarkdownProps = React.useMemo((): ReactMarkdownProps | undefined => { + if (!enabled || linkedRefs.length === 0) { + return undefined; + } + + return { + components: { + a: ({ children, href, ...props }: React.ComponentPropsWithoutRef<'a'>) => { + const resourceRef = href ? refsByPath.get(href) : undefined; + if (resourceRef) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); + }, + }, + }; + }, [enabled, k8sModels, linkedRefs, navigate, refsByPath]); + + return { content, reactMarkdownProps }; +}; diff --git a/src/hooks/useMessageSources.tsx b/src/hooks/useMessageSources.tsx new file mode 100644 index 00000000..4b297228 --- /dev/null +++ b/src/hooks/useMessageSources.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import type { SourcesCardProps } from '@patternfly/chatbot'; + +import { ReferencedDoc } from '../types'; + +const isURL = (value: string): boolean => { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +}; + +const buildDocSources = (references: ReferencedDoc[] | undefined): SourcesCardProps['sources'] => + (references ?? []) + .filter( + (reference) => + reference && + typeof reference.doc_title === 'string' && + typeof reference.doc_url === 'string' && + isURL(reference.doc_url), + ) + .map((reference) => ({ + isExternal: true, + link: reference.doc_url, + title: reference.doc_title, + })); + +type UseMessageSourcesOptions = { + enabled: boolean; + references?: ReferencedDoc[]; +}; + +type UseMessageSourcesResult = { + messageSources?: SourcesCardProps; +}; + +export const useMessageSources = ({ + enabled, + references, +}: UseMessageSourcesOptions): UseMessageSourcesResult => { + const { t } = useTranslation('plugin__lightspeed-console-plugin'); + + const docSources = React.useMemo( + () => (enabled ? buildDocSources(references) : []), + [enabled, references], + ); + + const messageSources = React.useMemo((): SourcesCardProps | undefined => { + if (docSources.length === 0) { + return undefined; + } + + return { + paginationAriaLabel: t('Evidence sources'), + sourceWord: t('source'), + sourceWordPlural: t('sources'), + sources: docSources, + toNextPageAriaLabel: t('Next source'), + toPreviousPageAriaLabel: t('Previous source'), + }; + }, [docSources, t]); + + return { messageSources }; +}; diff --git a/src/linkedResourceStatusDisplay.ts b/src/linkedResourceStatusDisplay.ts new file mode 100644 index 00000000..8082b979 --- /dev/null +++ b/src/linkedResourceStatusDisplay.ts @@ -0,0 +1,138 @@ +import { StatusSummary } from './resourceStatus'; + +/** + * StatusComponent maps `status` strings to icons in a switch statement; those case + * values are not exported from @openshift-console/dynamic-plugin-sdk. + * @see node_modules/@openshift-console/dynamic-plugin-sdk/lib/app/components/status/Status.js + */ + +/** Built-in kinds with custom summary text that should stay as text badges. */ +const TEXT_ONLY_BUILTIN_KINDS = new Set(['HorizontalPodAutoscaler']); + +/** Container waiting/terminated reasons that StatusComponent renders with an icon. */ +const CONTAINER_REASONS_WITH_ICON = new Set([ + 'ContainerCannotRun', + 'ContainerCreating', + 'CrashLoopBackOff', + 'ErrImagePull', + 'Error', + 'ImagePullBackOff', +]); + +export type LinkedResourceStatusDisplay = + | { mode: 'icon'; status: string; title: string } + | { mode: 'text' }; + +export type ConsoleStatusMapping = { + status: string; + useIcon: boolean; +}; + +export const usesConsoleStatusIcon = (kindName: string, modelKey: string): boolean => { + if (modelKey.includes('~')) { + return false; + } + return !TEXT_ONLY_BUILTIN_KINDS.has(kindName); +}; + +const mapReplicaLabel = (summary: StatusSummary): string => { + if (summary.variant === 'success') { + return 'Running'; + } + if (summary.variant === 'warning') { + return 'Warning'; + } + return 'Failed'; +}; + +const withIcon = (status: string): ConsoleStatusMapping => ({ status, useIcon: true }); +const asText = (status: string): ConsoleStatusMapping => ({ status, useIcon: false }); + +export const toConsoleStatusKey = ( + summary: StatusSummary, + kindName: string, +): ConsoleStatusMapping => { + const { label, variant } = summary; + + if (label === 'NotReady') { + return withIcon('Not Ready'); + } + + if (CONTAINER_REASONS_WITH_ICON.has(label)) { + return withIcon(label); + } + + if (label === 'LoadBalancer ready') { + return withIcon('Ready'); + } + + if (label.endsWith(' ready')) { + return withIcon(mapReplicaLabel(summary)); + } + + if (kindName === 'Job') { + if (variant === 'danger') { + return withIcon('Failed'); + } + if (label === 'Complete' || variant === 'success') { + return withIcon('Complete'); + } + } + + if (kindName === 'CronJob') { + if (label === 'Suspended') { + return withIcon('Warning'); + } + if (label.endsWith(' active')) { + return withIcon('Running'); + } + if (label === 'Scheduled') { + return withIcon('Ready'); + } + if (label === 'No runs yet') { + return withIcon('Info'); + } + } + + if ( + ['Running', 'Succeeded', 'Ready', 'Bound', 'Complete', 'Failed', 'Pending', 'Unknown'].includes( + label, + ) + ) { + return withIcon(label); + } + + // Unmapped condition/reason text — text badge (CRD phases, HPA reasons, etc.) + if (kindName !== 'Job' && kindName !== 'CronJob') { + return asText(label); + } + + if (variant === 'success') { + return withIcon('Ready'); + } + if (variant === 'warning') { + return withIcon('Warning'); + } + if (variant === 'danger') { + return withIcon('Failed'); + } + + return asText(label); +}; + +export const getLinkedResourceStatusDisplay = ( + summary: StatusSummary, + kindName: string, + modelKey: string, +): LinkedResourceStatusDisplay => { + if (!usesConsoleStatusIcon(kindName, modelKey)) { + return { mode: 'text' }; + } + + const mapped = toConsoleStatusKey(summary, kindName); + if (!mapped.useIcon) { + return { mode: 'text' }; + } + + return { mode: 'icon', status: mapped.status, title: summary.label }; +}; diff --git a/src/linkedResourceText.ts b/src/linkedResourceText.ts new file mode 100644 index 00000000..1c868bec --- /dev/null +++ b/src/linkedResourceText.ts @@ -0,0 +1,147 @@ +import { buildResourceConsolePath, K8sModelRef } from './pageContext'; +import { isVerticalListNoiseLine } from './resourceListParsing'; +import { ResourceRef } from './resourceRefs'; + +const CODE_FENCE_PATTERN = /```[\s\S]*?```/g; +const INLINE_CODE_PATTERN = /`[^`\n]+`/g; +const MARKDOWN_LINK_PATTERN = /\[[^\]]*\]\([^)]*\)/g; + +type Replacement = { end: number; replacement: string; start: number }; + +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const getProtectedRanges = (text: string): [number, number][] => { + const ranges: [number, number][] = []; + for (const pattern of [CODE_FENCE_PATTERN, INLINE_CODE_PATTERN, MARKDOWN_LINK_PATTERN]) { + pattern.lastIndex = 0; + for (const match of text.matchAll(pattern)) { + if (match.index === undefined) { + continue; + } + ranges.push([match.index, match.index + match[0].length]); + } + } + + let offset = 0; + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (trimmed && isVerticalListNoiseLine(trimmed)) { + ranges.push([offset, offset + line.length]); + } + offset += line.length + 1; + } + + return ranges; +}; + +const isProtected = (start: number, end: number, ranges: [number, number][]): boolean => + ranges.some(([rangeStart, rangeEnd]) => start >= rangeStart && end <= rangeEnd); + +const overlapsReplacement = (start: number, end: number, replacements: Replacement[]): boolean => + replacements.some((existing) => start < existing.end && end > existing.start); + +const collectReplacementsForRef = ( + text: string, + ref: ResourceRef, + path: string, + protectedRanges: [number, number][], + existing: Replacement[], +): Replacement[] => { + const replacements: Replacement[] = []; + const escaped = escapeRegExp(ref.name); + + const nameFieldPattern = new RegExp(`(^|\\n)(Name:\\s*)(${escaped})(?=\\s*(?:\\n|$))`, 'gi'); + for (const match of text.matchAll(nameFieldPattern)) { + if (match.index === undefined || match[3] === undefined) { + continue; + } + const prefixLength = match[1].length + match[2].length; + const start = match.index + prefixLength; + const end = start + match[3].length; + if ( + !isProtected(start, end, protectedRanges) && + !overlapsReplacement(start, end, existing) && + !overlapsReplacement(start, end, replacements) + ) { + replacements.push({ + end, + replacement: `[${ref.name}](${path})`, + start, + }); + } + } + + const boldPattern = new RegExp(`\\*\\*(${escaped})\\*\\*`, 'g'); + for (const match of text.matchAll(boldPattern)) { + if (match.index === undefined) { + continue; + } + const start = match.index; + const end = start + match[0].length; + if ( + !isProtected(start, end, protectedRanges) && + !overlapsReplacement(start, end, existing) && + !overlapsReplacement(start, end, replacements) + ) { + replacements.push({ + end, + replacement: `[${ref.name}](${path})`, + start, + }); + } + } + + const standalonePattern = new RegExp(`(^|\\n)(${escaped})(?=\\n|$)`, 'g'); + for (const match of text.matchAll(standalonePattern)) { + if (match.index === undefined || match[2] === undefined) { + continue; + } + const start = match.index + match[1].length; + const end = start + match[2].length; + if ( + !isProtected(start, end, protectedRanges) && + !overlapsReplacement(start, end, existing) && + !overlapsReplacement(start, end, replacements) + ) { + replacements.push({ + end, + replacement: `[${ref.name}](${path})`, + start, + }); + } + } + + return replacements; +}; + +export const injectResourceLinksInMarkdown = ( + text: string, + refs: ResourceRef[], + models: Record, +): string => { + if (!text || refs.length === 0) { + return text; + } + + const protectedRanges = getProtectedRanges(text); + const sortedRefs = [...refs].sort((a, b) => b.name.length - a.name.length); + const allReplacements: Replacement[] = []; + + for (const ref of sortedRefs) { + const path = buildResourceConsolePath(ref, models); + if (!path) { + continue; + } + allReplacements.push( + ...collectReplacementsForRef(text, ref, path, protectedRanges, allReplacements), + ); + } + + let result = text; + for (const replacement of allReplacements.sort((a, b) => b.start - a.start)) { + result = + result.slice(0, replacement.start) + replacement.replacement + result.slice(replacement.end); + } + + return result; +}; diff --git a/src/linkedResourceWatch.ts b/src/linkedResourceWatch.ts new file mode 100644 index 00000000..a2c2a3c1 --- /dev/null +++ b/src/linkedResourceWatch.ts @@ -0,0 +1,84 @@ +import { K8sResourceKind } from '@openshift-console/dynamic-plugin-sdk'; + +import { getModelKindName, K8sModelRef, resolveRefModelKey } from './pageContext'; +import { ResourceRef } from './resourceRefs'; + +export type ResourceWatchProps = { + isList: false; + kind: string; + name: string; + namespace?: string; +}; + +export const buildResourceWatchProps = ( + resourceRef: ResourceRef, + k8sModels: Record, +): ResourceWatchProps | null => { + const modelKey = resolveRefModelKey(resourceRef, k8sModels); + if (!modelKey) { + return null; + } + + const model = k8sModels[modelKey]; + if (model?.namespaced) { + if (!resourceRef.namespace) { + return null; + } + return { + isList: false, + kind: modelKey, + name: resourceRef.name, + namespace: resourceRef.namespace, + }; + } + + return { + isList: false, + kind: modelKey, + name: resourceRef.name, + }; +}; + +export const matchesResourceRef = ( + resource: K8sResourceKind | undefined, + resourceRef: ResourceRef, + k8sModels: Record, +): boolean => { + if (!resource?.metadata?.name) { + return false; + } + if (resource.metadata.name !== resourceRef.name) { + return false; + } + if ( + resourceRef.namespace !== undefined && + resource.metadata.namespace !== resourceRef.namespace + ) { + return false; + } + const modelKey = resolveRefModelKey(resourceRef, k8sModels); + if (modelKey && resource.kind) { + const expectedKind = getModelKindName(modelKey, k8sModels); + if (resource.kind !== expectedKind) { + return false; + } + } + return true; +}; + +/** Namespace/project phase (e.g. Active) reflects lifecycle, not console selection — skip the badge. */ +const KINDS_WITHOUT_STATUS_BADGE = new Set(['Namespace', 'ConfigMap', 'Secret']); + +export const shouldShowLinkedResourceStatus = ( + resourceRef: ResourceRef, + k8sModels: Record, +): boolean => { + const modelKey = resolveRefModelKey(resourceRef, k8sModels); + if (!modelKey) { + return false; + } + if (KINDS_WITHOUT_STATUS_BADGE.has(modelKey)) { + return false; + } + return true; +}; diff --git a/src/linkedResources.ts b/src/linkedResources.ts new file mode 100644 index 00000000..cbb467f8 --- /dev/null +++ b/src/linkedResources.ts @@ -0,0 +1,146 @@ +import { + getModelKindName, + K8sModelRef, + resolveRefModelKey, + resolveRefModelKeyOrKind, +} from './pageContext'; +import { hasBulkResourceListTool } from './resourceListParsing'; +import { + extractResourceRefs, + getMentionedResourceNames, + isPlausibleResourceName, + ResourceRef, + resourceRefKey, +} from './resourceRefs'; +import { Tool } from './types'; + +/** Default cap for mixed resource types (deployments, single pods, etc.). */ +export const MAX_LINKED_RESOURCES = 3; + +/** Higher cap when the response is driven by a namespace pod list tool. */ +export const MAX_LINKED_RESOURCES_POD_LIST = 12; + +export const prioritizeLinkedResources = ( + refs: ResourceRef[], + responseText?: string, + tools?: Record, +): ResourceRef[] => { + const mentionedNames = getMentionedResourceNames(responseText, tools).map((name) => + name.toLowerCase(), + ); + if (mentionedNames.length === 0) { + return refs; + } + + const prioritized: ResourceRef[] = []; + const used = new Set(); + + for (const name of mentionedNames) { + for (const ref of refs) { + if (ref.name.toLowerCase() !== name) { + continue; + } + const key = resourceRefKey(ref); + if (!used.has(key)) { + prioritized.push(ref); + used.add(key); + } + } + } + + for (const ref of refs) { + const key = resourceRefKey(ref); + if (!used.has(key)) { + prioritized.push(ref); + } + } + + return prioritized; +}; + +export const isPlausibleResourceRef = ( + ref: ResourceRef, + models: Record, +): boolean => { + const modelKey = resolveRefModelKeyOrKind(ref, models); + const kindName = getModelKindName(modelKey, models); + if (ref.name === kindName) { + return false; + } + return isPlausibleResourceName(ref.name); +}; + +export const isLinkableResource = ( + ref: ResourceRef, + models: Record, +): boolean => { + if (!isPlausibleResourceRef(ref, models)) { + return false; + } + + const modelKey = resolveRefModelKey(ref, models); + if (!modelKey) { + return false; + } + + const model = models[modelKey]; + if (model.namespaced && !ref.namespace) { + return false; + } + + return true; +}; + +const resolveLinkedResourceLimit = ( + tools: Record | undefined, + linkableCount: number, +): number => { + if (hasBulkResourceListTool(tools) && linkableCount > MAX_LINKED_RESOURCES) { + return Math.min(linkableCount, MAX_LINKED_RESOURCES_POD_LIST); + } + return MAX_LINKED_RESOURCES; +}; + +export const extractLinkableResourceRefs = ( + tools: Record | undefined, + responseText: string | undefined, + models: Record, +): ResourceRef[] => + extractResourceRefs(tools, responseText, models).filter((ref) => isLinkableResource(ref, models)); + +/** Capped list for a future footer LabelGroup; inline links use {@link getInlineLinkedResources}. */ +export const extractLinkedResources = ( + tools: Record | undefined, + responseText: string | undefined, + models: Record, +): ResourceRef[] => { + const linkable = extractLinkableResourceRefs(tools, responseText, models); + const limit = resolveLinkedResourceLimit(tools, linkable.length); + return prioritizeLinkedResources(linkable, responseText, tools).slice(0, limit); +}; + +export const getLinkedResourceOverflow = ( + tools: Record | undefined, + responseText: string | undefined, + models: Record, +): { shown: number; total: number } => { + const linkable = extractLinkableResourceRefs(tools, responseText, models); + const limit = resolveLinkedResourceLimit(tools, linkable.length); + const shown = prioritizeLinkedResources(linkable, responseText, tools).slice(0, limit).length; + return { shown, total: linkable.length }; +}; + +/** Linkable resources whose names appear in the response prose (for inline injection). */ +export const getInlineLinkedResources = ( + tools: Record | undefined, + responseText: string | undefined, + models: Record, +): ResourceRef[] => { + if (!responseText?.trim()) { + return []; + } + + return extractLinkableResourceRefs(tools, responseText, models).filter((ref) => + responseText.includes(ref.name), + ); +}; diff --git a/src/pageContext.ts b/src/pageContext.ts index e1cf02bd..05232c26 100644 --- a/src/pageContext.ts +++ b/src/pageContext.ts @@ -1,9 +1,25 @@ +import { isValidNamespaceName, isValidResourceName } from './validation'; + +export type K8sModelRef = { + kind?: string; + plural?: string; + namespaced?: boolean; +}; + +export type ResourceRef = { + kind: string; + name: string; + namespace?: string; + /** Use the OpenShift project route (/k8s/cluster/projects/…) instead of namespaces. */ + useProjectRoute?: boolean; +}; + // Resolve a URL resource key to a k8s model key. The URL segment can be a direct model key // (e.g. "Pod"), a plural (e.g. "pods"), or a group~version~kind reference where the model // is stored under just the kind (e.g. "core~v1~Pod" → "Pod"). export const resolveModelKey = ( urlKey: string, - models: Record, + models: Record, ): string | undefined => { if (models[urlKey]) { return urlKey; @@ -17,10 +33,136 @@ export const resolveModelKey = ( if (kindPart && models[kindPart]) { return kindPart; } + return Object.keys(models).find((key) => key === urlKey || key.endsWith(`~${kindPart}`)); } return undefined; }; +export const resolveKindToModelKey = ( + kind: string, + models: Record, +): string | undefined => { + if (kind === 'Project' && models.Namespace) { + return 'Namespace'; + } + + if (models[kind]) { + return kind; + } + + const byKindField = Object.keys(models).find((key) => models[key].kind === kind); + if (byKindField) { + return byKindField; + } + + if (kind.includes('~')) { + const kindPart = kind.split('~').pop(); + if (kindPart && models[kindPart]) { + return kindPart; + } + return Object.keys(models).find((key) => key === kind || key.endsWith(`~${kindPart}`)); + } + + return undefined; +}; + +/** Resolve a {@link ResourceRef} to a console k8s model key. */ +export const resolveRefModelKey = ( + ref: ResourceRef, + models: Record, +): string | undefined => { + if (ref.useProjectRoute && models.Namespace) { + return 'Namespace'; + } + return resolveKindToModelKey(ref.kind, models); +}; + +/** Like {@link resolveRefModelKey}, but falls back to `ref.kind` when models are unknown. */ +export const resolveRefModelKeyOrKind = ( + ref: ResourceRef, + models: Record, +): string => resolveRefModelKey(ref, models) ?? ref.kind; + +export const getModelKindName = (modelKey: string, models: Record): string => + models[modelKey]?.kind ?? modelKey.split('~').pop() ?? modelKey; + +/** Model reference for console ResourceIcon (e.g. Pod or group~version~kind for CRDs). */ +export const getResourceIconKind = ( + ref: ResourceRef, + models: Record, +): string => { + if (ref.useProjectRoute) { + return 'Project'; + } + return resolveRefModelKeyOrKind(ref, models); +}; + +export const isClusterScopedRef = ( + ref: ResourceRef, + models: Record, +): boolean => { + const modelKey = resolveRefModelKey(ref, models); + if (!modelKey) { + return false; + } + return models[modelKey]?.namespaced === false; +}; + +export const isNamespacedRef = (ref: ResourceRef, models: Record): boolean => { + const modelKey = resolveRefModelKey(ref, models); + if (!modelKey) { + return true; + } + return models[modelKey]?.namespaced !== false; +}; + +export const getModelUrlSegment = ( + modelKey: string, + models: Record, +): string | undefined => { + if (modelKey.includes('~')) { + return modelKey; + } + return models[modelKey]?.plural; +}; + +export const buildResourceConsolePath = ( + ref: ResourceRef, + models: Record, +): string | null => { + const { name, namespace } = ref; + if (!isValidResourceName(name)) { + return null; + } + if (namespace !== undefined && !isValidNamespaceName(namespace)) { + return null; + } + + const modelKey = resolveRefModelKey(ref, models); + if (!modelKey) { + return null; + } + + const model = models[modelKey]; + const urlSegment = getModelUrlSegment(modelKey, models); + if (!urlSegment) { + return null; + } + + if (modelKey === 'Namespace' && ref.useProjectRoute) { + return `/k8s/cluster/projects/${name}`; + } + + if (model.namespaced) { + if (!namespace) { + return null; + } + return `/k8s/ns/${namespace}/${urlSegment}/${name}`; + } + + return `/k8s/cluster/${urlSegment}/${name}`; +}; + export const buildPageContext = ( kind: string | undefined, name: string | undefined, diff --git a/src/resourceListParsing.ts b/src/resourceListParsing.ts new file mode 100644 index 00000000..89dbb2c9 --- /dev/null +++ b/src/resourceListParsing.ts @@ -0,0 +1,255 @@ +import { ResourceRef } from './pageContext'; +import { Tool } from './types'; + +export const K8S_NODE_NAME_RE = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; + +const RESOURCE_AGE_LINE = /^\d+([wdhms]\d*)+$/i; + +const NAMESPACE_STATUS_WORDS = new Set(['active', 'terminating']); + +const VERTICAL_LIST_SKIP_WORDS = new Set([ + 'active', + 'terminating', + 'running', + 'pending', + 'succeeded', + 'failed', + 'unknown', + 'ready', + 'true', + 'false', + 'available', + 'progressing', + 'replicafailure', + 'worker', + 'master', + 'control-plane', +]); + +const READY_FRACTION_LINE = /^\d+\/\d+$/; +const PERCENT_LINE = /^\d+%$/; +const IP_LINE = /^(?:\d{1,3}\.){3}\d{1,3}$/; + +const RESOURCE_LIST_TOOL_NAMES = new Set([ + 'resources_list', + 'pods_list', + 'pods_list_in_namespace', + 'namespaces_list', + 'projects_list', +]); + +const isApiVersionToken = (token: string): boolean => token === 'v1' || token.includes('/'); + +const isTableHeaderLine = (line: string): boolean => /^(NAME|NAMESPACE|APIVERSION)\b/i.test(line); + +export const isNamespaceStatusLine = (line: string): boolean => + NAMESPACE_STATUS_WORDS.has(line.toLowerCase()); + +export const isVerticalListNoiseLine = (line: string): boolean => { + const lower = line.toLowerCase(); + if (RESOURCE_AGE_LINE.test(line)) { + return true; + } + if (isNamespaceStatusLine(line)) { + return true; + } + if (VERTICAL_LIST_SKIP_WORDS.has(lower)) { + return true; + } + if (READY_FRACTION_LINE.test(line)) { + return true; + } + if (PERCENT_LINE.test(line)) { + return true; + } + if (IP_LINE.test(line)) { + return true; + } + if (/^\d+$/.test(line)) { + return true; + } + return false; +}; + +export type ResourceListToolContext = { + kind: string; + namespace?: string; +}; + +export const toNamespaceRef = (ref: ResourceRef): ResourceRef => + ref.kind === 'Project' ? { kind: 'Namespace', name: ref.name, useProjectRoute: true } : ref; + +export const extractResourcesFromMcpTableContent = (content: string): ResourceRef[] => { + const refs: ResourceRef[] = []; + const seen = new Set(); + + const add = (ref: ResourceRef) => { + const normalized = toNamespaceRef(ref); + const key = `${normalized.kind}/${normalized.namespace ?? ''}/${normalized.name}`; + if (seen.has(key)) { + return; + } + seen.add(key); + refs.push(normalized); + }; + + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || isTableHeaderLine(trimmed)) { + continue; + } + + const parts = trimmed.split(/\s+/); + if (parts.length < 3) { + continue; + } + + if (isApiVersionToken(parts[0])) { + const kind = parts[1]; + const name = parts[2]; + add({ kind, name }); + continue; + } + + if (parts.length >= 4 && isApiVersionToken(parts[1])) { + const namespace = parts[0]; + const kind = parts[2]; + const name = parts[3]; + add({ kind, name, namespace }); + } + } + + return refs; +}; + +export const applyListToolNamespaceDefault = ( + refs: ResourceRef[], + namespace?: string, +): ResourceRef[] => { + if (!namespace) { + return refs; + } + return refs.map((ref) => (ref.namespace ? ref : { ...ref, namespace })); +}; + +export const resourceListToolContextFromTool = (tool: Tool): ResourceListToolContext | null => { + if (tool.isDenied || tool.status === 'error') { + return null; + } + + const args = tool.args ?? {}; + + switch (tool.name) { + case 'resources_list': { + const kind = typeof args.kind === 'string' ? args.kind : undefined; + if (!kind) { + return null; + } + const namespace = typeof args.namespace === 'string' ? args.namespace : undefined; + return { kind, namespace }; + } + case 'pods_list': + return { kind: 'Pod' }; + case 'pods_list_in_namespace': { + const namespace = typeof args.namespace === 'string' ? args.namespace : undefined; + if (!namespace) { + return null; + } + return { kind: 'Pod', namespace }; + } + case 'namespaces_list': + case 'projects_list': + return { kind: 'Namespace' }; + case 'nodes_top': + if (typeof args.name === 'string' && args.name) { + return null; + } + return { kind: 'Node' }; + default: + return null; + } +}; + +export const getResourceListToolContexts = ( + tools?: Record, +): ResourceListToolContext[] => { + const contexts: ResourceListToolContext[] = []; + const seen = new Set(); + + for (const tool of Object.values(tools ?? {})) { + const context = resourceListToolContextFromTool(tool); + if (!context) { + continue; + } + const key = `${context.kind}/${context.namespace ?? ''}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + contexts.push(context); + } + + return contexts; +}; + +export const isResourceListToolName = (toolName?: string): boolean => + !!toolName && RESOURCE_LIST_TOOL_NAMES.has(toolName); + +export const hasBulkResourceListTool = (tools?: Record): boolean => + getResourceListToolContexts(tools).length > 0; + +const lineMatchesListContext = (line: string, context: ResourceListToolContext): boolean => { + if (context.kind === 'Node') { + return line.includes('.') && K8S_NODE_NAME_RE.test(line); + } + if (context.kind === 'Namespace') { + return !isNamespaceStatusLine(line); + } + return true; +}; + +export const extractResourcesFromVerticalListing = ( + text: string, + contexts: ResourceListToolContext[], + isPlausibleName: (name: string) => boolean, +): ResourceRef[] => { + if (contexts.length === 0) { + return []; + } + + const refs: ResourceRef[] = []; + const seen = new Set(); + + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.includes(' ')) { + continue; + } + if (isVerticalListNoiseLine(trimmed)) { + continue; + } + if (!isPlausibleName(trimmed)) { + continue; + } + + for (const context of contexts) { + if (!lineMatchesListContext(trimmed, context)) { + continue; + } + const ref: ResourceRef = { + kind: context.kind, + name: trimmed, + namespace: context.namespace, + }; + const key = `${ref.kind}/${ref.namespace ?? ''}/${ref.name}`; + if (seen.has(key)) { + break; + } + seen.add(key); + refs.push(ref); + break; + } + } + + return refs; +}; diff --git a/src/resourceRefs.ts b/src/resourceRefs.ts new file mode 100644 index 00000000..0151f62b --- /dev/null +++ b/src/resourceRefs.ts @@ -0,0 +1,676 @@ +import { load as loadYAML } from 'js-yaml'; + +import { K8sModelRef, resolveKindToModelKey, resolveRefModelKey, ResourceRef } from './pageContext'; +import { + applyListToolNamespaceDefault, + extractResourcesFromMcpTableContent, + extractResourcesFromVerticalListing, + getResourceListToolContexts, + hasBulkResourceListTool, + isResourceListToolName, + isVerticalListNoiseLine, + K8S_NODE_NAME_RE, + toNamespaceRef, +} from './resourceListParsing'; +import { Tool } from './types'; + +export type { ResourceRef } from './pageContext'; +export { buildResourceConsolePath } from './pageContext'; + +const TEXT_RESOURCE_PATTERN = /\b([A-Z][a-zA-Z0-9]+)\s+[`'"]?([a-z0-9][a-z0-9.-]*)/g; + +const POD_NAME_FIELD_PATTERN = /\bName:\s*([a-z0-9][a-z0-9.-]*)/gi; + +const POD_NAME_SUFFIX_PATTERN = /\b([a-z0-9][a-z0-9.-]*)\s+pods?\b/gi; + +const NAMESPACE_PATTERN = /namespace\s+[`'"]?([a-z0-9][a-z0-9-]*)[`'"]?/gi; + +const K8S_RESOURCE_NAME_RE = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/; + +const RESOURCE_NAME_STOP_WORDS = new Set([ + 'is', + 'will', + 'object', + 'not', + 'has', + 'are', + 'was', + 'were', + 'been', + 'being', + 'the', + 'and', + 'for', + 'that', + 'this', + 'when', + 'more', + 'some', + 'any', + 'all', + 'can', + 'may', + 'our', + 'out', + 'own', + 'too', + 'very', + 'just', + 'now', + 'then', + 'than', + 'also', + 'into', + 'over', + 'such', + 'only', + 'other', + 'about', + 'after', + 'before', + 'between', + 'during', + 'without', + 'within', + 'never', + 'always', + 'properly', + 'configured', + 'means', + 'need', + 'check', + 'consider', + 'failure', + 'ensure', + 'working', + 'active', + 'finish', + 'updating', + 'unavailable', +]); + +export const isPlausibleResourceName = ( + name: string, + options?: { fromText?: boolean }, +): boolean => { + if (!name || name.length < 2) { + return false; + } + if (isVerticalListNoiseLine(name)) { + return false; + } + if (!K8S_RESOURCE_NAME_RE.test(name)) { + return false; + } + if (RESOURCE_NAME_STOP_WORDS.has(name)) { + return false; + } + // Prose like "ClusterVersion object" or "MachineConfigPool will" — require workload-like names. + if (options?.fromText && !/[0-9-]/.test(name)) { + return false; + } + return true; +}; + +export const extractNodesFromTopContent = (content: string): ResourceRef[] => { + const refs: ResourceRef[] = []; + const seen = new Set(); + + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || /^NAME\b/i.test(trimmed)) { + continue; + } + + const name = trimmed.split(/\s+/)[0]; + if (!name || !K8S_NODE_NAME_RE.test(name) || !isPlausibleResourceName(name)) { + continue; + } + + const key = name.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + refs.push({ kind: 'Node', name }); + } + + return refs; +}; + +export const extractNamespacesFromListContent = (content: string): ResourceRef[] => + extractResourcesFromMcpTableContent(content).filter((ref) => ref.kind === 'Namespace'); + +export const extractNodesFromResourcesListContent = (content: string): ResourceRef[] => + extractResourcesFromMcpTableContent(content).filter((ref) => ref.kind === 'Node'); + +export const hasNodesTopListTool = (tools?: Record): boolean => + Object.values(tools ?? {}).some((tool) => { + if (tool.isDenied || tool.status === 'error' || tool.name !== 'nodes_top') { + return false; + } + if (typeof tool.args?.name === 'string' && tool.args.name) { + return false; + } + return extractNodesFromTopContent(tool.content ?? '').length > 0; + }); + +export const hasResourceListNodesTool = (tools?: Record): boolean => + Object.values(tools ?? {}).some((tool) => { + if (tool.isDenied || tool.status === 'error' || tool.name !== 'resources_list') { + return false; + } + return tool.args?.kind === 'Node'; + }); + +export const hasBulkNodeListTool = (tools?: Record): boolean => + hasNodesTopListTool(tools) || hasResourceListNodesTool(tools); + +export const hasNamespacesListTool = (tools?: Record): boolean => + getResourceListToolContexts(tools).some((context) => context.kind === 'Namespace'); + +export const hasBulkNamespaceListTool = (tools?: Record): boolean => + hasNamespacesListTool(tools); + +export const hasPodListTool = (tools?: Record): boolean => + getResourceListToolContexts(tools).some((context) => context.kind === 'Pod'); + +type EventInvolvedObject = { + Kind?: string; + Name?: string; + Namespace?: string; + kind?: string; + name?: string; + namespace?: string; +}; + +type EventDocument = { + InvolvedObject?: EventInvolvedObject; + involvedObject?: EventInvolvedObject; + Namespace?: string; + metadata?: { namespace?: string }; +}; + +const pickStringField = ( + record: Record, + ...keys: string[] +): string | undefined => { + for (const key of keys) { + const value = record[key]; + if (typeof value === 'string' && value) { + return value; + } + } + return undefined; +}; + +const extractRefsFromStructuredListItems = ( + structured: Record | undefined, + toolArgs: Record, + toolName?: string, +): ResourceRef[] => { + const items = structured?.items; + if (!Array.isArray(items)) { + return []; + } + + const defaultKind = + typeof toolArgs.kind === 'string' + ? toolArgs.kind + : toolName === 'namespaces_list' + ? 'Namespace' + : toolName === 'projects_list' + ? 'Project' + : undefined; + const defaultNamespace = typeof toolArgs.namespace === 'string' ? toolArgs.namespace : undefined; + const refs: ResourceRef[] = []; + + for (const item of items) { + if (!item || typeof item !== 'object') { + continue; + } + const row = item as Record; + const name = pickStringField(row, 'Name', 'name'); + const kind = pickStringField(row, 'kind', 'Kind') ?? defaultKind; + if (!name || !kind) { + continue; + } + const namespace = pickStringField(row, 'Namespace', 'namespace') ?? defaultNamespace; + refs.push(toNamespaceRef({ kind, name, namespace })); + } + + return refs; +}; + +const listElementKind = (listKind: string): string | undefined => + listKind.endsWith('List') ? listKind.slice(0, -4) : undefined; + +const extractRefFromObject = (obj: unknown, defaultKind?: string): ResourceRef | null => { + if (!obj || typeof obj !== 'object') { + return null; + } + const record = obj as { kind?: string; metadata?: { name?: string; namespace?: string } }; + const kind = record.kind ?? defaultKind; + const name = record.metadata?.name; + const namespace = record.metadata?.namespace; + if (kind && name) { + return { kind, name, namespace }; + } + return null; +}; + +const extractRefsFromDocumentArray = (parsed: unknown): ResourceRef[] => { + if (!Array.isArray(parsed)) { + return []; + } + + const refs: ResourceRef[] = []; + for (const item of parsed) { + const ref = extractRefFromObject(item); + if (ref) { + refs.push(toNamespaceRef(ref)); + } + } + return refs; +}; + +export const extractRefsFromEventsDocument = (parsed: unknown): ResourceRef[] => { + const events = Array.isArray(parsed) ? parsed : parsed ? [parsed] : []; + const refs: ResourceRef[] = []; + const seen = new Set(); + + for (const event of events) { + if (!event || typeof event !== 'object') { + continue; + } + const record = event as EventDocument; + const involved = record.InvolvedObject ?? record.involvedObject; + if (!involved) { + continue; + } + const kind = involved.Kind ?? involved.kind; + const name = involved.Name ?? involved.name; + if (!kind || !name || !isPlausibleResourceName(name)) { + continue; + } + const namespace = + involved.Namespace ?? involved.namespace ?? record.Namespace ?? record.metadata?.namespace; + const ref = toNamespaceRef({ kind, name, namespace }); + const key = `${ref.kind}/${ref.namespace ?? ''}/${ref.name}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + refs.push(ref); + } + + return refs; +}; + +const hasPodInvestigationTools = (tools?: Record): boolean => + Object.values(tools ?? {}).some( + (tool) => tool.name.startsWith('pods_') || tool.name === 'events_list', + ); + +const extractRefsFromListDocument = ( + parsed: { kind?: string; items?: unknown[] }, + fallbackNamespace?: string, +): ResourceRef[] => { + if (!Array.isArray(parsed.items)) { + return []; + } + const defaultKind = parsed.kind ? listElementKind(parsed.kind) : undefined; + const refs: ResourceRef[] = []; + for (const item of parsed.items) { + const ref = extractRefFromObject(item, defaultKind); + if (!ref) { + continue; + } + refs.push({ + kind: ref.kind, + name: ref.name, + namespace: ref.namespace ?? fallbackNamespace, + }); + } + return refs; +}; + +const resolveModelKeyFromToolPrefix = ( + toolPrefix: string, + models: Record, +): string | undefined => { + const normalized = toolPrefix.toLowerCase(); + return Object.keys(models).find((key) => { + const model = models[key]; + return ( + key.toLowerCase() === normalized || + model.kind?.toLowerCase() === normalized || + model.plural?.toLowerCase() === normalized + ); + }); +}; + +export const normalizeResourceRef = ( + ref: ResourceRef, + models: Record, +): ResourceRef | null => { + if (!isPlausibleResourceName(ref.name)) { + return null; + } + const modelKey = resolveRefModelKey(ref, models); + if (!modelKey) { + return null; + } + return { + kind: modelKey, + name: ref.name, + namespace: ref.namespace, + ...(ref.useProjectRoute ? { useProjectRoute: true } : {}), + }; +}; + +export const extractResourceFromToolArgs = ( + toolName: string, + args: Record, + models: Record, +): ResourceRef | null => { + const name = typeof args.name === 'string' ? args.name : undefined; + const namespace = typeof args.namespace === 'string' ? args.namespace : undefined; + + if (toolName === 'pods_get' || toolName === 'pods_log' || toolName === 'pods_delete') { + return name ? { kind: 'Pod', name, namespace } : null; + } + + if (toolName === 'resources_get' || toolName === 'resources_delete') { + const kind = typeof args.kind === 'string' ? args.kind : undefined; + if (kind && name) { + return { kind, name, namespace }; + } + } + + if (toolName === 'resources_list' || toolName === 'resources_scale') { + const kind = typeof args.kind === 'string' ? args.kind : undefined; + if (kind && namespace) { + return { kind, name: kind, namespace }; + } + } + + if (toolName === 'nodes_log' || toolName === 'nodes_top') { + return name ? { kind: 'Node', name } : null; + } + + if (name) { + const modelKey = resolveModelKeyFromToolPrefix(toolName.split('_')[0], models); + if (modelKey) { + return { kind: modelKey, name, namespace }; + } + } + + return null; +}; + +export const extractResourcesFromToolContent = ( + content: string, + toolName?: string, + toolArgs?: Record, +): ResourceRef[] => { + if (!content) { + return []; + } + + const fallbackNamespace = + typeof toolArgs?.namespace === 'string' ? toolArgs.namespace : undefined; + + try { + const parsed = JSON.parse(content) as unknown; + const fromArray = extractRefsFromDocumentArray(parsed); + if (fromArray.length > 0) { + return fromArray; + } + const listDoc = parsed as { kind?: string; items?: unknown[] }; + const fromList = extractRefsFromListDocument(listDoc, fallbackNamespace); + if (fromList.length > 0) { + return fromList; + } + const single = extractRefFromObject(parsed); + if (single) { + return [toNamespaceRef(single)]; + } + } catch { + // Not JSON. + } + + try { + const parsed = loadYAML(content) as unknown; + if (toolName === 'events_list') { + const fromEvents = extractRefsFromEventsDocument(parsed); + if (fromEvents.length > 0) { + return fromEvents; + } + } + const fromArray = extractRefsFromDocumentArray(parsed); + if (fromArray.length > 0) { + return fromArray; + } + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const listDoc = parsed as { kind?: string; items?: unknown[] }; + const fromList = extractRefsFromListDocument(listDoc, fallbackNamespace); + if (fromList.length > 0) { + return fromList; + } + const single = extractRefFromObject(parsed); + if (single) { + return [toNamespaceRef(single)]; + } + } + } catch { + // Not YAML. + } + + if (toolName === 'nodes_top') { + return extractNodesFromTopContent(content); + } + + if (isResourceListToolName(toolName)) { + const fromTable = extractResourcesFromMcpTableContent(content); + if (fromTable.length > 0) { + return applyListToolNamespaceDefault(fromTable, fallbackNamespace); + } + } + + return []; +}; + +export const extractResourceFromToolContent = (content: string): ResourceRef | null => + extractResourcesFromToolContent(content)[0] ?? null; + +export const extractResourcesFromText = ( + text: string, + models: Record, + tools?: Record, +): ResourceRef[] => { + const refs: ResourceRef[] = []; + const namespaces = [...text.matchAll(NAMESPACE_PATTERN)].map((m) => m[1]); + const defaultNamespace = namespaces[0]; + + for (const match of text.matchAll(TEXT_RESOURCE_PATTERN)) { + const kindCandidate = match[1]; + const name = match[2]; + if (!isPlausibleResourceName(name, { fromText: true })) { + continue; + } + const modelKey = resolveKindToModelKey(kindCandidate, models); + if (!modelKey) { + continue; + } + refs.push({ + kind: modelKey, + name, + namespace: defaultNamespace, + }); + } + + const podsNamespace = tools + ? Object.values(tools).find((tool) => tool.name === 'pods_list_in_namespace')?.args?.namespace + : undefined; + const podNamespace = typeof podsNamespace === 'string' ? podsNamespace : defaultNamespace; + + if (tools && hasPodInvestigationTools(tools)) { + for (const match of text.matchAll(POD_NAME_FIELD_PATTERN)) { + const name = match[1]; + if (!isPlausibleResourceName(name)) { + continue; + } + refs.push({ + kind: 'Pod', + name, + namespace: podNamespace, + }); + } + + for (const match of text.matchAll(POD_NAME_SUFFIX_PATTERN)) { + const name = match[1]; + if (!isPlausibleResourceName(name, { fromText: true })) { + continue; + } + refs.push({ + kind: 'Pod', + name, + namespace: podNamespace, + }); + } + } + + if (tools && hasBulkResourceListTool(tools)) { + const listContexts = getResourceListToolContexts(tools); + for (const ref of extractResourcesFromVerticalListing( + text, + listContexts, + isPlausibleResourceName, + )) { + refs.push(ref); + } + } + + return refs; +}; + +const resourceKey = (ref: ResourceRef): string => `${ref.kind}/${ref.namespace ?? ''}/${ref.name}`; + +export const resourceRefKey = resourceKey; + +export const getMentionedPodNames = (responseText?: string): string[] => { + if (!responseText) { + return []; + } + + const names: string[] = []; + const seen = new Set(); + const addName = (name: string) => { + if (!isPlausibleResourceName(name, { fromText: true })) { + return; + } + const key = name.toLowerCase(); + if (!seen.has(key)) { + seen.add(key); + names.push(name); + } + }; + for (const match of responseText.matchAll(POD_NAME_FIELD_PATTERN)) { + addName(match[1]); + } + for (const match of responseText.matchAll(POD_NAME_SUFFIX_PATTERN)) { + addName(match[1]); + } + return names; +}; + +export const getMentionedNodeNames = (responseText?: string): string[] => + extractResourcesFromVerticalListing( + responseText ?? '', + [{ kind: 'Node' }], + isPlausibleResourceName, + ).map((ref) => ref.name); + +export const getMentionedResourceNames = ( + responseText?: string, + tools?: Record, +): string[] => { + const names: string[] = []; + const seen = new Set(); + const addName = (name: string) => { + const key = name.toLowerCase(); + if (!seen.has(key)) { + seen.add(key); + names.push(name); + } + }; + + for (const name of getMentionedPodNames(responseText)) { + addName(name); + } + if (hasBulkResourceListTool(tools)) { + for (const ref of extractResourcesFromVerticalListing( + responseText ?? '', + getResourceListToolContexts(tools), + isPlausibleResourceName, + )) { + addName(ref.name); + } + } + return names; +}; + +export const getMentionedNamespaceNames = (responseText?: string): string[] => + extractResourcesFromVerticalListing( + responseText ?? '', + [{ kind: 'Namespace' }], + isPlausibleResourceName, + ).map((ref) => ref.name); + +export const getPreferredResourceName = (responseText?: string): string | undefined => + getMentionedPodNames(responseText)[0]; + +export const extractResourceRefs = ( + tools: Record | undefined, + responseText: string | undefined, + models: Record, +): ResourceRef[] => { + const seen = new Set(); + const refs: ResourceRef[] = []; + + const addRef = (ref: ResourceRef | null) => { + const normalized = ref ? normalizeResourceRef(ref, models) : null; + if (!normalized) { + return; + } + const key = resourceKey(normalized); + if (seen.has(key)) { + return; + } + seen.add(key); + refs.push(normalized); + }; + + if (tools) { + Object.values(tools).forEach((tool) => { + if (tool.isDenied || tool.status === 'error') { + return; + } + const args = (tool.args ?? {}) as Record; + addRef(extractResourceFromToolArgs(tool.name, args, models)); + extractResourcesFromToolContent(tool.content, tool.name, args).forEach((ref) => { + addRef(ref); + }); + extractRefsFromStructuredListItems(tool.structuredContent, args, tool.name).forEach((ref) => { + addRef(ref); + }); + }); + } + + if (responseText) { + extractResourcesFromText(responseText, models, tools).forEach(addRef); + } + + return refs; +}; diff --git a/src/resourceStatus.ts b/src/resourceStatus.ts new file mode 100644 index 00000000..aaff8c69 --- /dev/null +++ b/src/resourceStatus.ts @@ -0,0 +1,284 @@ +import { K8sResourceKind } from '@openshift-console/dynamic-plugin-sdk'; + +import { getGenericResourceStatus, variantForPhase } from './crStatus'; +import { getModelKindName, K8sModelRef } from './pageContext'; + +export type StatusSummary = { + label: string; + variant: 'success' | 'warning' | 'danger' | 'info'; +}; + +const NON_INFORMATIVE_STATUS_LABELS = new Set(['', '-', '–', '—']); + +/** Hide badges when status text carries no useful cluster signal (e.g. em dash for status-less kinds). */ +export const isInformativeStatusLabel = (label: string): boolean => { + const trimmed = label.trim(); + return trimmed.length > 0 && !NON_INFORMATIVE_STATUS_LABELS.has(trimmed); +}; + +type ResourceCondition = { + message?: string; + reason?: string; + status?: string; + type?: string; +}; + +const statusFromPhase = (phase: string): StatusSummary => ({ + label: phase, + variant: variantForPhase(phase), +}); + +type ContainerStatus = { + ready?: boolean; + state?: { + waiting?: { reason?: string }; + terminated?: { reason?: string }; + }; +}; + +const DANGER_CONTAINER_REASONS = new Set([ + 'ContainerCannotRun', + 'CrashLoopBackOff', + 'CreateContainerConfigError', + 'CreateContainerError', + 'ErrImagePull', + 'Error', + 'ImagePullBackOff', + 'InvalidImageName', +]); + +const WARNING_CONTAINER_REASONS = new Set(['ContainerCreating', 'PodInitializing']); + +const containerReasonVariant = (reason: string): StatusSummary['variant'] => { + if (DANGER_CONTAINER_REASONS.has(reason)) { + return 'danger'; + } + if (WARNING_CONTAINER_REASONS.has(reason)) { + return 'warning'; + } + return 'warning'; +}; + +const statusFromContainerStatuses = ( + statuses: ContainerStatus[] | undefined, +): StatusSummary | undefined => { + for (const container of statuses ?? []) { + const waitingReason = container.state?.waiting?.reason; + if (waitingReason) { + return { label: waitingReason, variant: containerReasonVariant(waitingReason) }; + } + } + return undefined; +}; + +/** Match console/kubectl: container waiting/terminated reasons override pod phase. */ +const podStatus = (resource: K8sResourceKind): StatusSummary => { + const phase = resource.status?.phase ?? 'Unknown'; + + const fromInit = statusFromContainerStatuses(resource.status?.initContainerStatuses); + if (fromInit) { + return fromInit; + } + + const fromContainers = statusFromContainerStatuses(resource.status?.containerStatuses); + if (fromContainers) { + return fromContainers; + } + + for (const container of (resource.status?.containerStatuses ?? []) as ContainerStatus[]) { + const terminatedReason = container.state?.terminated?.reason; + if ( + terminatedReason && + terminatedReason !== 'Completed' && + phase !== 'Running' && + phase !== 'Succeeded' + ) { + return { + label: terminatedReason === 'Error' ? 'Error' : terminatedReason, + variant: 'danger', + }; + } + } + + if (phase === 'Running' || phase === 'Succeeded') { + return { label: phase, variant: 'success' }; + } + if (phase === 'Pending') { + return { label: phase, variant: 'warning' }; + } + if (phase === 'Failed') { + return { label: 'Failed', variant: 'danger' }; + } + return statusFromPhase(phase); +}; + +const workloadReplicaStatus = (resource: K8sResourceKind): StatusSummary => { + const ready = resource.status?.readyReplicas ?? resource.status?.numberReady ?? 0; + const total = + resource.status?.replicas ?? + resource.status?.desiredNumberScheduled ?? + resource.status?.replicas ?? + ready; + const label = `${ready}/${total} ready`; + if (total === 0) { + return { label, variant: 'info' }; + } + if (ready === total) { + return { label, variant: 'success' }; + } + if (ready > 0) { + return { label, variant: 'warning' }; + } + return { label, variant: 'danger' }; +}; + +const jobStatus = (resource: K8sResourceKind): StatusSummary => { + const conditions = resource.status?.conditions as ResourceCondition[] | undefined; + const failed = resource.status?.failed ?? 0; + const active = resource.status?.active ?? 0; + + if (failed > 0) { + return { label: 'Failed', variant: 'danger' }; + } + + const failedCondition = conditions?.find( + (condition) => condition.type === 'Failed' && condition.status === 'True', + ); + if (failedCondition) { + return { label: failedCondition.reason || 'Failed', variant: 'danger' }; + } + + const completeCondition = conditions?.find( + (condition) => condition.type === 'Complete' && condition.status === 'True', + ); + if (completeCondition) { + return { label: completeCondition.reason || 'Complete', variant: 'success' }; + } + + if (active > 0) { + return { label: 'Running', variant: 'info' }; + } + + return { label: 'Pending', variant: 'warning' }; +}; + +const cronJobStatus = (resource: K8sResourceKind): StatusSummary => { + if (resource.spec?.suspend) { + return { label: 'Suspended', variant: 'warning' }; + } + + const active = (resource.status?.active as unknown[] | undefined)?.length ?? 0; + if (active > 0) { + return { label: `${active} active`, variant: 'info' }; + } + + if (resource.status?.lastSuccessfulTime) { + return { label: 'Scheduled', variant: 'success' }; + } + + return { label: 'No runs yet', variant: 'info' }; +}; + +const loadBalancerReady = (resource: K8sResourceKind): boolean => { + const ingress = resource.status?.loadBalancer?.ingress as unknown[] | undefined; + return (ingress?.length ?? 0) > 0; +}; + +const hpaStatus = (resource: K8sResourceKind): StatusSummary => { + const conditions = resource.status?.conditions as ResourceCondition[] | undefined; + const scalingLimited = conditions?.find( + (condition) => condition.type === 'ScalingLimited' && condition.status === 'True', + ); + if (scalingLimited) { + return { label: scalingLimited.reason || 'ScalingLimited', variant: 'warning' }; + } + + const unableToScale = conditions?.find( + (condition) => condition.type === 'AbleToScale' && condition.status === 'False', + ); + if (unableToScale) { + return { label: unableToScale.reason || 'Unable to scale', variant: 'danger' }; + } + + const current = resource.status?.currentReplicas; + const desired = resource.status?.desiredReplicas; + if (current !== undefined && desired !== undefined) { + return { + label: `${current}/${desired} replicas`, + variant: current === desired ? 'success' : 'warning', + }; + } + + return getGenericResourceStatus(resource); +}; + +export const getResourceStatusSummary = ( + kindName: string, + resource?: K8sResourceKind, +): StatusSummary => { + if (!resource) { + return { label: '—', variant: 'info' }; + } + + switch (kindName) { + case 'Pod': + return podStatus(resource); + case 'Deployment': + case 'StatefulSet': + case 'DaemonSet': + case 'ReplicaSet': + case 'ReplicationController': + case 'DeploymentConfig': + return workloadReplicaStatus(resource); + case 'Job': + return jobStatus(resource); + case 'CronJob': + return cronJobStatus(resource); + case 'PersistentVolumeClaim': + case 'PersistentVolume': { + const phase = resource.status?.phase; + if (typeof phase === 'string') { + return statusFromPhase(phase); + } + return getGenericResourceStatus(resource); + } + case 'Namespace': { + const phase = resource.status?.phase ?? 'Active'; + return statusFromPhase(phase); + } + case 'Service': { + const type = resource.spec?.type ?? 'ClusterIP'; + if (type === 'LoadBalancer') { + return loadBalancerReady(resource) + ? { label: 'LoadBalancer ready', variant: 'success' } + : { label: 'Pending', variant: 'warning' }; + } + // ClusterIP, NodePort, and ExternalName expose type in spec — no runtime status to show. + return { label: '—', variant: 'info' }; + } + case 'Ingress': + return loadBalancerReady(resource) + ? { label: 'Ready', variant: 'success' } + : { label: 'Pending', variant: 'warning' }; + case 'HorizontalPodAutoscaler': + return hpaStatus(resource); + case 'Node': { + const readyCondition = resource.status?.conditions?.find( + (condition: ResourceCondition) => condition.type === 'Ready', + ); + const isReady = readyCondition?.status === 'True'; + return { + label: isReady ? 'Ready' : 'NotReady', + variant: isReady ? 'success' : 'danger', + }; + } + default: + return getGenericResourceStatus(resource); + } +}; + +export const getResourceStatusSummaryForRef = ( + modelKey: string, + models: Record, + resource?: K8sResourceKind, +): StatusSummary => getResourceStatusSummary(getModelKindName(modelKey, models), resource); diff --git a/unit-tests/consoleNavigation.test.ts b/unit-tests/consoleNavigation.test.ts new file mode 100644 index 00000000..99bddc3c --- /dev/null +++ b/unit-tests/consoleNavigation.test.ts @@ -0,0 +1,12 @@ +import { describe, it } from 'node:test'; +import { strictEqual } from 'node:assert'; + +import { navigateToConsolePath } from '../src/consoleNavigation'; + +describe('navigateToConsolePath', () => { + it('ignores empty paths', () => { + navigateToConsolePath(''); + navigateToConsolePath('#'); + strictEqual(true, true); + }); +}); diff --git a/unit-tests/crStatus.test.ts b/unit-tests/crStatus.test.ts new file mode 100644 index 00000000..e030160e --- /dev/null +++ b/unit-tests/crStatus.test.ts @@ -0,0 +1,76 @@ +import { describe, it } from 'node:test'; +import { strictEqual } from 'node:assert'; + +import { getGenericResourceStatus } from '../src/crStatus'; + +const csvStatus = { + phase: 'Succeeded', + reason: 'InstallSucceeded', + message: 'install strategy completed with no errors', + conditions: [ + { + lastTransitionTime: '2026-06-30T08:47:39Z', + message: 'requirements not yet checked', + phase: 'Pending', + reason: 'RequirementsUnknown', + }, + { + lastTransitionTime: '2026-06-30T09:11:07Z', + message: 'install strategy completed with no errors', + phase: 'Succeeded', + reason: 'InstallSucceeded', + }, + ], +}; + +describe('getGenericResourceStatus', () => { + it('prefers top-level phase over historical conditions', () => { + const summary = getGenericResourceStatus({ status: csvStatus }); + strictEqual(summary.label, 'Succeeded (InstallSucceeded)'); + strictEqual(summary.variant, 'success'); + }); + + it('uses standard Ready conditions for kubebuilder CRDs', () => { + const summary = getGenericResourceStatus({ + status: { + conditions: [{ type: 'Ready', status: 'True', reason: 'Ready' }], + }, + }); + strictEqual(summary.label, 'Ready'); + strictEqual(summary.variant, 'success'); + }); + + it('uses latest phase condition when only OLM-style conditions exist', () => { + const summary = getGenericResourceStatus({ + status: { + conditions: [ + { + lastTransitionTime: '2026-06-30T08:47:39Z', + phase: 'Pending', + reason: 'RequirementsUnknown', + }, + { + lastTransitionTime: '2026-06-30T09:06:44Z', + phase: 'Failed', + reason: 'ComponentUnhealthy', + }, + ], + }, + }); + strictEqual(summary.label, 'Failed (ComponentUnhealthy)'); + strictEqual(summary.variant, 'danger'); + }); + + it('prefers Degraded over Available when both are True', () => { + const summary = getGenericResourceStatus({ + status: { + conditions: [ + { type: 'Available', status: 'True', reason: 'MinimumReplicasAvailable' }, + { type: 'Degraded', status: 'True', reason: 'RolloutStalled' }, + ], + }, + }); + strictEqual(summary.label, 'RolloutStalled'); + strictEqual(summary.variant, 'danger'); + }); +}); diff --git a/unit-tests/fixtures/k8sModels.ts b/unit-tests/fixtures/k8sModels.ts new file mode 100644 index 00000000..05438777 --- /dev/null +++ b/unit-tests/fixtures/k8sModels.ts @@ -0,0 +1,75 @@ +import { K8sModelRef } from '../../src/pageContext'; + +export const testK8sModels: Record = { + ConfigMap: { kind: 'ConfigMap', plural: 'configmaps', namespaced: true }, + CronJob: { kind: 'CronJob', plural: 'cronjobs', namespaced: true }, + DaemonSet: { kind: 'DaemonSet', plural: 'daemonsets', namespaced: true }, + Deployment: { kind: 'Deployment', plural: 'deployments', namespaced: true }, + DeploymentConfig: { kind: 'DeploymentConfig', plural: 'deploymentconfigs', namespaced: true }, + HorizontalPodAutoscaler: { + kind: 'HorizontalPodAutoscaler', + plural: 'horizontalpodautoscalers', + namespaced: true, + }, + Ingress: { kind: 'Ingress', plural: 'ingresses', namespaced: true }, + Job: { kind: 'Job', plural: 'jobs', namespaced: true }, + Node: { kind: 'Node', plural: 'nodes', namespaced: false }, + Namespace: { kind: 'Namespace', plural: 'namespaces', namespaced: false }, + Pod: { kind: 'Pod', plural: 'pods', namespaced: true }, + ReplicaSet: { kind: 'ReplicaSet', plural: 'replicasets', namespaced: true }, + ReplicationController: { + kind: 'ReplicationController', + plural: 'replicationcontrollers', + namespaced: true, + }, + PersistentVolume: { kind: 'PersistentVolume', plural: 'persistentvolumes', namespaced: false }, + PersistentVolumeClaim: { + kind: 'PersistentVolumeClaim', + plural: 'persistentvolumeclaims', + namespaced: true, + }, + ServiceAccount: { kind: 'ServiceAccount', plural: 'serviceaccounts', namespaced: true }, + 'config.openshift.io~v1~ClusterVersion': { + kind: 'ClusterVersion', + plural: 'clusterversions', + namespaced: false, + }, + 'machineconfiguration.openshift.io~v1~MachineConfigPool': { + kind: 'MachineConfigPool', + plural: 'machineconfigpools', + namespaced: false, + }, + 'operators.coreos.com~v1alpha1~Subscription': { + kind: 'Subscription', + plural: 'subscriptions', + namespaced: true, + }, + 'operator.openshift.io~v1~IngressController': { + kind: 'IngressController', + plural: 'ingresscontrollers', + namespaced: false, + }, + 'route.openshift.io~v1~Route': { + kind: 'Route', + plural: 'routes', + namespaced: true, + }, + Secret: { kind: 'Secret', plural: 'secrets', namespaced: true }, + Service: { kind: 'Service', plural: 'services', namespaced: true }, + StatefulSet: { kind: 'StatefulSet', plural: 'statefulsets', namespaced: true }, + 'kubevirt.io~v1~VirtualMachine': { + kind: 'VirtualMachine', + plural: 'virtualmachines', + namespaced: true, + }, + 'flows.netobserv.io~v1beta2~FlowCollector': { + kind: 'FlowCollector', + plural: 'flowcollectors', + namespaced: false, + }, + 'flows.netobserv.io~v1beta2~FlowCollectorSlice': { + kind: 'FlowCollectorSlice', + plural: 'flowcollectorslices', + namespaced: true, + }, +}; diff --git a/unit-tests/linkedResourceStatusDisplay.test.ts b/unit-tests/linkedResourceStatusDisplay.test.ts new file mode 100644 index 00000000..40290adb --- /dev/null +++ b/unit-tests/linkedResourceStatusDisplay.test.ts @@ -0,0 +1,115 @@ +import { describe, it } from 'node:test'; +import { deepStrictEqual, strictEqual } from 'node:assert'; + +import { + getLinkedResourceStatusDisplay, + toConsoleStatusKey, + usesConsoleStatusIcon, +} from '../src/linkedResourceStatusDisplay'; + +describe('usesConsoleStatusIcon', () => { + it('returns true for built-in Pod', () => { + strictEqual(usesConsoleStatusIcon('Pod', 'Pod'), true); + }); + + it('returns false for CRD model keys', () => { + strictEqual( + usesConsoleStatusIcon('FlowCollector', 'flows.netobserv.io~v1beta2~FlowCollector'), + false, + ); + }); + + it('returns false for HPA', () => { + strictEqual(usesConsoleStatusIcon('HorizontalPodAutoscaler', 'HorizontalPodAutoscaler'), false); + }); +}); + +describe('toConsoleStatusKey', () => { + it('maps pod phases with icons', () => { + deepStrictEqual(toConsoleStatusKey({ label: 'Running', variant: 'success' }, 'Pod'), { + status: 'Running', + useIcon: true, + }); + deepStrictEqual(toConsoleStatusKey({ label: 'Failed', variant: 'danger' }, 'Pod'), { + status: 'Failed', + useIcon: true, + }); + }); + + it('maps container reasons with icons', () => { + deepStrictEqual(toConsoleStatusKey({ label: 'CrashLoopBackOff', variant: 'danger' }, 'Pod'), { + status: 'CrashLoopBackOff', + useIcon: true, + }); + }); + + it('maps LoadBalancer ready before generic replica labels', () => { + deepStrictEqual( + toConsoleStatusKey({ label: 'LoadBalancer ready', variant: 'success' }, 'Service'), + { status: 'Ready', useIcon: true }, + ); + }); + + it('maps replica counts to console workload icons', () => { + deepStrictEqual(toConsoleStatusKey({ label: '1/1 ready', variant: 'success' }, 'Deployment'), { + status: 'Running', + useIcon: true, + }); + deepStrictEqual(toConsoleStatusKey({ label: '1/2 ready', variant: 'warning' }, 'Deployment'), { + status: 'Warning', + useIcon: true, + }); + deepStrictEqual(toConsoleStatusKey({ label: '0/2 ready', variant: 'danger' }, 'Deployment'), { + status: 'Failed', + useIcon: true, + }); + }); + + it('maps node readiness', () => { + deepStrictEqual(toConsoleStatusKey({ label: 'NotReady', variant: 'danger' }, 'Node'), { + status: 'Not Ready', + useIcon: true, + }); + }); + + it('falls back to text for unmapped operator reasons', () => { + deepStrictEqual( + toConsoleStatusKey( + { label: 'ScalingLimited', variant: 'warning' }, + 'HorizontalPodAutoscaler', + ), + { status: 'ScalingLimited', useIcon: false }, + ); + }); +}); + +describe('getLinkedResourceStatusDisplay', () => { + it('returns icon mode for pods', () => { + deepStrictEqual( + getLinkedResourceStatusDisplay({ label: 'Running', variant: 'success' }, 'Pod', 'Pod'), + { mode: 'icon', status: 'Running', title: 'Running' }, + ); + }); + + it('returns text mode for operator CRDs', () => { + strictEqual( + getLinkedResourceStatusDisplay( + { label: 'Ready', variant: 'success' }, + 'FlowCollector', + 'flows.netobserv.io~v1beta2~FlowCollector', + ).mode, + 'text', + ); + }); + + it('returns text mode for unmapped custom labels on built-in kinds', () => { + strictEqual( + getLinkedResourceStatusDisplay( + { label: 'ScalingLimited', variant: 'warning' }, + 'HorizontalPodAutoscaler', + 'HorizontalPodAutoscaler', + ).mode, + 'text', + ); + }); +}); diff --git a/unit-tests/linkedResourceText.test.ts b/unit-tests/linkedResourceText.test.ts new file mode 100644 index 00000000..2835476a --- /dev/null +++ b/unit-tests/linkedResourceText.test.ts @@ -0,0 +1,137 @@ +import { describe, it } from 'node:test'; +import { strictEqual } from 'node:assert'; + +import { injectResourceLinksInMarkdown } from '../src/linkedResourceText'; +import { testK8sModels } from './fixtures/k8sModels'; + +describe('injectResourceLinksInMarkdown', () => { + it('links pod names on Name: lines without touching age values', () => { + const response = `Here are pods in namespace default: + +Name: mock-pod-a +Ready: 1/1 +Status: Running +Restarts: 0 +Age: 5m + +Name: mock-pod-b +Ready: 1/1 +Status: Running +Restarts: 0 +Age: 5m`; + + const linked = injectResourceLinksInMarkdown( + response, + [ + { kind: 'Pod', name: 'mock-pod-a', namespace: 'default' }, + { kind: 'Pod', name: 'mock-pod-b', namespace: 'default' }, + ], + testK8sModels, + ); + + strictEqual( + linked, + `Here are pods in namespace default: + +Name: [mock-pod-a](/k8s/ns/default/pods/mock-pod-a) +Ready: 1/1 +Status: Running +Restarts: 0 +Age: 5m + +Name: [mock-pod-b](/k8s/ns/default/pods/mock-pod-b) +Ready: 1/1 +Status: Running +Restarts: 0 +Age: 5m`, + ); + }); + + it('wraps bold resource mentions in prose', () => { + const response = + 'The lightspeed-postgres-server pod is healthy. Check **lightspeed-postgres-server** again.'; + const linked = injectResourceLinksInMarkdown( + response, + [ + { + kind: 'Pod', + name: 'lightspeed-postgres-server', + namespace: 'openshift-lightspeed', + }, + ], + testK8sModels, + ); + + strictEqual( + linked, + 'The lightspeed-postgres-server pod is healthy. Check [lightspeed-postgres-server](/k8s/ns/openshift-lightspeed/pods/lightspeed-postgres-server) again.', + ); + }); + + it('links a standalone resource name line in legacy listings', () => { + const response = `Here are the pods in the "openshift-lightspeed" namespace: + +lightspeed-app-server-abc +2/2 +Running +0 +72m`; + + const linked = injectResourceLinksInMarkdown( + response, + [ + { + kind: 'Pod', + name: 'lightspeed-app-server-abc', + namespace: 'openshift-lightspeed', + }, + ], + testK8sModels, + ); + + strictEqual( + linked, + `Here are the pods in the "openshift-lightspeed" namespace: + +[lightspeed-app-server-abc](/k8s/ns/openshift-lightspeed/pods/lightspeed-app-server-abc) +2/2 +Running +0 +72m`, + ); + }); + + it('does not link resource names inside inline code', () => { + const response = 'Use `lightspeed-app-server-abc` as the pod name.'; + const linked = injectResourceLinksInMarkdown( + response, + [ + { + kind: 'Pod', + name: 'lightspeed-app-server-abc', + namespace: 'openshift-lightspeed', + }, + ], + testK8sModels, + ); + + strictEqual(linked, response); + }); + + it('does not replace shorter names inside longer resource names', () => { + const response = 'Name: lightspeed-app-server-abc-extra'; + const linked = injectResourceLinksInMarkdown( + response, + [ + { + kind: 'Pod', + name: 'lightspeed-app-server-abc', + namespace: 'openshift-lightspeed', + }, + ], + testK8sModels, + ); + + strictEqual(linked, response); + }); +}); diff --git a/unit-tests/linkedResourceWatch.test.ts b/unit-tests/linkedResourceWatch.test.ts new file mode 100644 index 00000000..bb4291a4 --- /dev/null +++ b/unit-tests/linkedResourceWatch.test.ts @@ -0,0 +1,106 @@ +import { describe, it } from 'node:test'; +import { strictEqual } from 'node:assert'; + +import { + buildResourceWatchProps, + matchesResourceRef, + shouldShowLinkedResourceStatus, +} from '../src/linkedResourceWatch'; +import { testK8sModels } from './fixtures/k8sModels'; + +describe('buildResourceWatchProps', () => { + it('watches Namespace for project refs', () => { + const watchProps = buildResourceWatchProps( + { kind: 'Namespace', name: 'payments', useProjectRoute: true }, + testK8sModels, + ); + strictEqual(watchProps?.kind, 'Namespace'); + strictEqual(watchProps?.name, 'payments'); + strictEqual(watchProps?.namespace, undefined); + }); + + it('watches namespaced resources in their namespace', () => { + const watchProps = buildResourceWatchProps( + { kind: 'Pod', name: 'api-1', namespace: 'payments' }, + testK8sModels, + ); + strictEqual(watchProps?.kind, 'Pod'); + strictEqual(watchProps?.name, 'api-1'); + strictEqual(watchProps?.namespace, 'payments'); + }); +}); + +describe('matchesResourceRef', () => { + it('rejects stale watch data for a different resource name', () => { + strictEqual( + matchesResourceRef( + { metadata: { name: 'default' } }, + { kind: 'Namespace', name: 'kube-system' }, + testK8sModels, + ), + false, + ); + }); + + it('rejects stale watch data for a different kind', () => { + strictEqual( + matchesResourceRef( + { kind: 'Pod', metadata: { name: 'api-1', namespace: 'payments' } }, + { kind: 'Deployment', name: 'api-1', namespace: 'payments' }, + testK8sModels, + ), + false, + ); + }); + + it('accepts matching namespace resources', () => { + strictEqual( + matchesResourceRef( + { metadata: { name: 'payments' } }, + { kind: 'Namespace', name: 'payments', useProjectRoute: true }, + testK8sModels, + ), + true, + ); + }); +}); + +describe('shouldShowLinkedResourceStatus', () => { + it('skips namespace, project, and data-only refs', () => { + strictEqual( + shouldShowLinkedResourceStatus( + { kind: 'Namespace', name: 'default', useProjectRoute: true }, + testK8sModels, + ), + false, + ); + strictEqual( + shouldShowLinkedResourceStatus({ kind: 'Project', name: 'payments' }, testK8sModels), + false, + ); + strictEqual( + shouldShowLinkedResourceStatus( + { kind: 'ConfigMap', name: 'app-config', namespace: 'default' }, + testK8sModels, + ), + false, + ); + strictEqual( + shouldShowLinkedResourceStatus( + { kind: 'Secret', name: 'tls', namespace: 'default' }, + testK8sModels, + ), + false, + ); + }); + + it('shows status for other resource kinds', () => { + strictEqual( + shouldShowLinkedResourceStatus( + { kind: 'Pod', name: 'api-1', namespace: 'payments' }, + testK8sModels, + ), + true, + ); + }); +}); diff --git a/unit-tests/linkedResources.test.ts b/unit-tests/linkedResources.test.ts new file mode 100644 index 00000000..bbba8d01 --- /dev/null +++ b/unit-tests/linkedResources.test.ts @@ -0,0 +1,416 @@ +import { describe, it } from 'node:test'; +import { strictEqual } from 'node:assert'; + +import { + extractLinkedResources, + getInlineLinkedResources, + getLinkedResourceOverflow, + isLinkableResource, + isPlausibleResourceRef, + MAX_LINKED_RESOURCES, + MAX_LINKED_RESOURCES_POD_LIST, + prioritizeLinkedResources, +} from '../src/linkedResources'; +import { Tool } from '../src/types'; +import { testK8sModels } from './fixtures/k8sModels'; + +describe('isLinkableResource', () => { + it('accepts cluster-scoped CRDs such as FlowCollector', () => { + strictEqual( + isLinkableResource({ kind: 'FlowCollector', name: 'cluster' }, testK8sModels), + true, + ); + }); + + it('accepts namespaced CRDs with a namespace', () => { + strictEqual( + isLinkableResource( + { + kind: 'FlowCollectorSlice', + name: 'tenant-a', + namespace: 'netobserv', + }, + testK8sModels, + ), + true, + ); + }); + + it('rejects list placeholders where the name equals the kind', () => { + strictEqual( + isPlausibleResourceRef( + { kind: 'Deployment', name: 'Deployment', namespace: 'payments' }, + testK8sModels, + ), + false, + ); + }); +}); + +describe('prioritizeLinkedResources', () => { + it('moves the pod named in the response text to the front', () => { + const refs = prioritizeLinkedResources( + [ + { + kind: 'Pod', + name: 'lightspeed-app-server-6975d49c5c-5rdlk', + namespace: 'openshift-lightspeed', + }, + { + kind: 'Pod', + name: 'lightspeed-console-plugin-588df96978-wcjqj', + namespace: 'openshift-lightspeed', + }, + ], + 'Name: lightspeed-console-plugin-588df96978-wcjqj\nStatus: Running', + ); + + strictEqual(refs[0].name, 'lightspeed-console-plugin-588df96978-wcjqj'); + }); + + it('prioritizes every pod named in the response before unmentioned pods', () => { + const refs = prioritizeLinkedResources( + [ + { kind: 'Pod', name: 'pod-a', namespace: 'ns' }, + { kind: 'Pod', name: 'pod-b', namespace: 'ns' }, + { kind: 'Pod', name: 'pod-c', namespace: 'ns' }, + { kind: 'Pod', name: 'pod-d', namespace: 'ns' }, + ], + 'Name: pod-d\nName: pod-b', + ); + + strictEqual(refs[0].name, 'pod-d'); + strictEqual(refs[1].name, 'pod-b'); + strictEqual(refs[2].name, 'pod-a'); + strictEqual(refs[3].name, 'pod-c'); + }); +}); + +describe('extractLinkedResources', () => { + it('returns watchable resources from tools', () => { + const tools: Record = { + t1: { + args: { name: 'payments-api', namespace: 'payments' }, + content: '', + name: 'pods_get', + status: 'success', + }, + t2: { + args: { + kind: 'Deployment', + name: 'reporting-service', + namespace: 'shared-services', + }, + content: '', + name: 'resources_get', + status: 'success', + }, + }; + + const refs = extractLinkedResources(tools, undefined, testK8sModels); + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Pod'); + strictEqual(refs[1].kind, 'Deployment'); + }); + + it('includes FlowCollector from resources_get', () => { + const tools: Record = { + t1: { + args: { kind: 'FlowCollector', name: 'cluster' }, + content: '', + name: 'resources_get', + status: 'success', + }, + }; + + const refs = extractLinkedResources(tools, undefined, testK8sModels); + strictEqual(refs.length, 1); + strictEqual(refs[0].kind, 'flows.netobserv.io~v1beta2~FlowCollector'); + strictEqual(refs[0].name, 'cluster'); + }); + + it('caps the number of live widgets', () => { + const tools: Record = Object.fromEntries( + Array.from({ length: MAX_LINKED_RESOURCES + 2 }, (_, index) => [ + `t${index}`, + { + args: { name: `pod-${index}`, namespace: 'payments' }, + content: '', + name: 'pods_get', + status: 'success', + } satisfies Tool, + ]), + ); + + strictEqual( + extractLinkedResources(tools, undefined, testK8sModels).length, + MAX_LINKED_RESOURCES, + ); + }); + + it('skips resources without a namespace except cluster-scoped kinds', () => { + const tools: Record = { + t1: { + args: { name: 'payments-api' }, + content: '', + name: 'pods_get', + status: 'success', + }, + t2: { + args: { name: 'worker-1' }, + content: '', + name: 'nodes_top', + status: 'success', + }, + }; + + const refs = extractLinkedResources(tools, undefined, testK8sModels); + strictEqual(refs.length, 1); + strictEqual(refs[0].kind, 'Node'); + }); + + it('includes pods from pods_list_in_namespace table content', () => { + const tableContent = `openshift-lightspeed v1 Pod lightspeed-app-server-abc 2/2 Running 0 1m 10.0.0.1 node `; + const refs = extractLinkedResources( + { + t1: { + args: { namespace: 'openshift-lightspeed' }, + content: tableContent, + name: 'pods_list_in_namespace', + status: 'success', + }, + }, + undefined, + testK8sModels, + ); + strictEqual(refs.length, 1); + strictEqual(refs[0].name, 'lightspeed-app-server-abc'); + }); + + it('includes every pod from a namespace pod list up to the pod-list cap', () => { + const tableRows = Array.from( + { length: 4 }, + (_, index) => + `openshift-lightspeed v1 Pod lightspeed-pod-${index} 1/1 Running 0 1m 10.0.0.${index} node `, + ).join('\n'); + const tools: Record = { + t1: { + args: { namespace: 'openshift-lightspeed' }, + content: tableRows, + name: 'pods_list_in_namespace', + status: 'success', + }, + }; + + const refs = extractLinkedResources(tools, undefined, testK8sModels); + strictEqual(refs.length, 4); + strictEqual(refs[3].name, 'lightspeed-pod-3'); + }); + + it('includes pods from events_list involved objects', () => { + const eventsContent = `# The following events (YAML format) were found: +- InvolvedObject: + Kind: Pod + Name: redhat-operators-2mhcl + Namespace: openshift-redhat-operators + Type: Warning`; + + const refs = extractLinkedResources( + { + t1: { args: {}, content: '', name: 'pods_list', status: 'success' }, + t2: { args: {}, content: eventsContent, name: 'events_list', status: 'success' }, + }, + 'Warning related to the redhat-operators-2mhcl pod.', + testK8sModels, + ); + + strictEqual(refs.length, 1); + strictEqual(refs[0].kind, 'Pod'); + strictEqual(refs[0].name, 'redhat-operators-2mhcl'); + strictEqual(refs[0].namespace, 'openshift-redhat-operators'); + }); + + it('includes nodes from nodes_top table content', () => { + const tableContent = `NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%) +ip-10-0-110-168.us-west-1.compute.internal 117m 3% 3233Mi 22% +ip-10-0-19-247.us-west-1.compute.internal 710m 20% 10120Mi 69% +ip-10-0-52-226.us-west-1.compute.internal 148m 4% 3019Mi 20% +ip-10-0-82-49.us-west-1.compute.internal 182m 5% 2968Mi 20%`; + const tools: Record = { + t1: { + args: {}, + content: tableContent, + name: 'nodes_top', + status: 'success', + }, + }; + + const refs = extractLinkedResources(tools, undefined, testK8sModels); + strictEqual(refs.length, 4); + strictEqual(refs[0].kind, 'Node'); + strictEqual(refs[0].name, 'ip-10-0-110-168.us-west-1.compute.internal'); + }); + + it('includes nodes from resources_list table content', () => { + const tableContent = `APIVERSION KIND NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME +v1 Node ip-10-0-110-168.us-west-1.compute.internal Ready worker 3h17m Red Hat Enterprise Linux CoreOS 10.0.110.168 +v1 Node ip-10-0-19-247.us-west-1.compute.internal Ready control-plane,master 3h35m Red Hat Enterprise Linux CoreOS 10.0.19.247 +v1 Node ip-10-0-52-226.us-west-1.compute.internal Ready worker 3h16m Red Hat Enterprise Linux CoreOS 10.0.52.226 +v1 Node ip-10-0-82-49.us-west-1.compute.internal Ready worker 3h17m Red Hat Enterprise Linux CoreOS 10.0.82.49`; + const tools: Record = { + t1: { + args: { apiVersion: 'v1', kind: 'Node' }, + content: tableContent, + name: 'resources_list', + status: 'success', + }, + }; + + const refs = extractLinkedResources(tools, undefined, testK8sModels); + strictEqual(refs.length, 4); + strictEqual(refs[0].kind, 'Node'); + strictEqual(refs[0].name, 'ip-10-0-110-168.us-west-1.compute.internal'); + }); + + it('includes namespaces from namespaces_list table content', () => { + const tableContent = `APIVERSION KIND NAME STATUS AGE LABELS +v1 Namespace default Active 3h38m kubernetes.io/metadata.name=default +v1 Namespace kube-system Active 3h38m kubernetes.io/metadata.name=kube-system +v1 Namespace openshift Active 3h33m kubernetes.io/metadata.name=openshift +v1 Namespace openshift-lightspeed Active 169m pod-security.kubernetes.io/enforce=restricted`; + const tools: Record = { + t1: { + args: {}, + content: tableContent, + name: 'namespaces_list', + status: 'success', + }, + }; + + const refs = extractLinkedResources(tools, undefined, testK8sModels); + strictEqual(refs.length, 4); + strictEqual(refs[0].kind, 'Namespace'); + strictEqual(refs[0].name, 'default'); + }); + + it('includes namespaces from LLM vertical listing when namespaces_list ran', () => { + const tools: Record = { + t1: { + args: {}, + content: '', + name: 'namespaces_list', + status: 'success', + }, + }; + const response = `Here are the OpenShift namespaces in the current cluster: + +default +Active +3h38m +openshift-lightspeed +Active +169m`; + + const refs = extractLinkedResources(tools, response, testK8sModels); + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Namespace'); + strictEqual(refs[0].name, 'default'); + strictEqual(refs[1].name, 'openshift-lightspeed'); + }); + + it('includes deployments from resources_list table content', () => { + const tableContent = `NAMESPACE APIVERSION KIND NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR +payments apps/v1 Deployment payments-api 2/2 2 2 3h10m payments-api registry/payments:latest app=payments +payments apps/v1 Deployment reporting-service 1/1 1 1 2h5m reporting-service registry/reporting:latest app=reporting`; + const tools: Record = { + t1: { + args: { apiVersion: 'apps/v1', kind: 'Deployment', namespace: 'payments' }, + content: tableContent, + name: 'resources_list', + status: 'success', + }, + }; + + const refs = extractLinkedResources(tools, undefined, testK8sModels); + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Deployment'); + strictEqual(refs[0].name, 'payments-api'); + }); + + it('prioritizes pods mentioned in the response before applying the default cap', () => { + const tableRows = [ + 'openshift-lightspeed v1 Pod lightspeed-pod-0 1/1 Running 0 1m 10.0.0.0 node ', + 'openshift-lightspeed v1 Pod lightspeed-pod-1 1/1 Running 0 1m 10.0.0.1 node ', + 'openshift-lightspeed v1 Pod lightspeed-pod-2 1/1 Running 0 1m 10.0.0.2 node ', + 'openshift-lightspeed v1 Pod lightspeed-postgres-server-77dd7fb495-r6gsf 1/1 Running 1 3m 10.130.0.20 node ', + ].join('\n'); + const tools: Record = { + t1: { + args: { namespace: 'openshift-lightspeed' }, + content: tableRows, + name: 'pods_list_in_namespace', + status: 'success', + }, + }; + const responseText = `Pods in openshift-lightspeed are healthy. + +Name: lightspeed-postgres-server-77dd7fb495-r6gsf +Ready: 1/1 +Restarts: 1`; + + const refs = extractLinkedResources(tools, responseText, testK8sModels); + strictEqual(refs.length, 4); + strictEqual(refs[0].name, 'lightspeed-postgres-server-77dd7fb495-r6gsf'); + }); + + it('reports overflow when more resources exist than the live widget cap', () => { + const tableRows = Array.from( + { length: MAX_LINKED_RESOURCES_POD_LIST + 2 }, + (_, index) => + `openshift-lightspeed v1 Pod lightspeed-pod-${index} 1/1 Running 0 1m 10.0.0.${index} node `, + ).join('\n'); + const tools: Record = { + t1: { + args: { namespace: 'openshift-lightspeed' }, + content: tableRows, + name: 'pods_list_in_namespace', + status: 'success', + }, + }; + + const overflow = getLinkedResourceOverflow(tools, undefined, testK8sModels); + strictEqual(overflow.shown, MAX_LINKED_RESOURCES_POD_LIST); + strictEqual(overflow.total, MAX_LINKED_RESOURCES_POD_LIST + 2); + }); +}); + +describe('getInlineLinkedResources', () => { + it('returns only linkable resources whose names appear in the response text', () => { + const tools: Record = { + t1: { + args: { name: 'payments-api', namespace: 'payments' }, + content: '', + name: 'pods_get', + status: 'success', + }, + t2: { + args: { + kind: 'Deployment', + name: 'reporting-service', + namespace: 'shared-services', + }, + content: '', + name: 'resources_get', + status: 'success', + }, + }; + + const refs = getInlineLinkedResources( + tools, + 'The payments-api pod is healthy but the deployment was not discussed.', + testK8sModels, + ); + strictEqual(refs.length, 1); + strictEqual(refs[0].name, 'payments-api'); + }); +}); diff --git a/unit-tests/pageContext.test.ts b/unit-tests/pageContext.test.ts index dc6f580a..88447e6a 100644 --- a/unit-tests/pageContext.test.ts +++ b/unit-tests/pageContext.test.ts @@ -1,7 +1,18 @@ import { describe, it } from 'node:test'; import { strictEqual } from 'node:assert'; -import { buildPageContext, resolveModelKey } from '../src/pageContext'; +import { + buildPageContext, + buildResourceConsolePath, + getResourceIconKind, + isClusterScopedRef, + isNamespacedRef, + resolveKindToModelKey, + resolveModelKey, + resolveRefModelKey, + resolveRefModelKeyOrKind, +} from '../src/pageContext'; +import { testK8sModels } from './fixtures/k8sModels'; describe('buildPageContext', () => { it('returns undefined when kind is undefined', () => { @@ -44,51 +55,229 @@ describe('buildPageContext', () => { }); }); -const models = { - Pod: { kind: 'Pod', plural: 'pods' }, - Deployment: { kind: 'Deployment', plural: 'deployments' }, - Node: { kind: 'Node', plural: 'nodes' }, - Secret: { kind: 'Secret', plural: 'secrets' }, - 'kubevirt.io~v1~VirtualMachine': { kind: 'VirtualMachine', plural: 'virtualmachines' }, -}; - describe('resolveModelKey', () => { it('resolves a direct model key', () => { - strictEqual(resolveModelKey('Pod', models), 'Pod'); + strictEqual(resolveModelKey('Pod', testK8sModels), 'Pod'); }); it('resolves a CRD model key with group~version~kind', () => { strictEqual( - resolveModelKey('kubevirt.io~v1~VirtualMachine', models), + resolveModelKey('kubevirt.io~v1~VirtualMachine', testK8sModels), 'kubevirt.io~v1~VirtualMachine', ); }); it('resolves a plural name to the model key', () => { - strictEqual(resolveModelKey('pods', models), 'Pod'); + strictEqual(resolveModelKey('pods', testK8sModels), 'Pod'); }); it('resolves a plural CRD name to the model key', () => { - strictEqual(resolveModelKey('virtualmachines', models), 'kubevirt.io~v1~VirtualMachine'); + strictEqual(resolveModelKey('virtualmachines', testK8sModels), 'kubevirt.io~v1~VirtualMachine'); }); it('resolves core~v1~Kind by extracting the kind portion', () => { - strictEqual(resolveModelKey('core~v1~Pod', models), 'Pod'); + strictEqual(resolveModelKey('core~v1~Pod', testK8sModels), 'Pod'); }); it('resolves core~v1~Deployment by extracting the kind portion', () => { - strictEqual(resolveModelKey('core~v1~Deployment', models), 'Deployment'); + strictEqual(resolveModelKey('core~v1~Deployment', testK8sModels), 'Deployment'); }); it('returns undefined for an unknown key', () => { - strictEqual(resolveModelKey('UnknownResource', models), undefined); + strictEqual(resolveModelKey('UnknownResource', testK8sModels), undefined); }); it('returns undefined for an unknown group~version~kind', () => { - strictEqual(resolveModelKey('fake.io~v1~Nothing', models), undefined); + strictEqual(resolveModelKey('fake.io~v1~Nothing', testK8sModels), undefined); }); it('returns undefined for an empty string', () => { - strictEqual(resolveModelKey('', models), undefined); + strictEqual(resolveModelKey('', testK8sModels), undefined); + }); +}); + +describe('resolveKindToModelKey', () => { + it('resolves a direct model key', () => { + strictEqual(resolveKindToModelKey('Pod', testK8sModels), 'Pod'); + }); + + it('resolves a Kubernetes kind name to the model key', () => { + strictEqual( + resolveKindToModelKey('VirtualMachine', testK8sModels), + 'kubevirt.io~v1~VirtualMachine', + ); + }); + + it('resolves a group~version~kind reference', () => { + strictEqual( + resolveKindToModelKey('kubevirt.io~v1~VirtualMachine', testK8sModels), + 'kubevirt.io~v1~VirtualMachine', + ); + }); + + it('returns undefined for unknown kinds', () => { + strictEqual(resolveKindToModelKey('NotARealKind', testK8sModels), undefined); + }); + + it('resolves Project kind to Namespace model key', () => { + strictEqual(resolveKindToModelKey('Project', testK8sModels), 'Namespace'); + }); +}); + +describe('getResourceIconKind', () => { + it('returns model key for built-in kinds', () => { + strictEqual( + getResourceIconKind({ kind: 'Pod', name: 'api-1', namespace: 'default' }, testK8sModels), + 'Pod', + ); + }); + + it('returns model key for CRD refs', () => { + strictEqual( + getResourceIconKind({ kind: 'ClusterVersion', name: 'version' }, testK8sModels), + 'config.openshift.io~v1~ClusterVersion', + ); + }); + + it('uses Project icon for project routes', () => { + strictEqual( + getResourceIconKind( + { kind: 'Namespace', name: 'payments', useProjectRoute: true }, + testK8sModels, + ), + 'Project', + ); + }); +}); + +describe('resolveRefModelKey', () => { + it('resolves kind names and model keys from a resource ref', () => { + strictEqual( + resolveRefModelKey({ kind: 'Pod', name: 'api-1', namespace: 'default' }, testK8sModels), + 'Pod', + ); + strictEqual( + resolveRefModelKey({ kind: 'ClusterVersion', name: 'version' }, testK8sModels), + 'config.openshift.io~v1~ClusterVersion', + ); + strictEqual(resolveRefModelKey({ kind: 'NotARealKind', name: 'x' }, testK8sModels), undefined); + }); + + it('resolves project-route refs to the Namespace model', () => { + strictEqual( + resolveRefModelKey( + { kind: 'Namespace', name: 'payments', useProjectRoute: true }, + testK8sModels, + ), + 'Namespace', + ); + }); +}); + +describe('resolveRefModelKeyOrKind', () => { + it('falls back to ref.kind when the model is unknown', () => { + strictEqual( + resolveRefModelKeyOrKind({ kind: 'CustomKind', name: 'example' }, testK8sModels), + 'CustomKind', + ); + }); +}); + +describe('resource scope helpers', () => { + it('detects cluster-scoped refs', () => { + strictEqual(isClusterScopedRef({ kind: 'Node', name: 'worker-1' }, testK8sModels), true); + strictEqual( + isClusterScopedRef( + { kind: 'flows.netobserv.io~v1beta2~FlowCollector', name: 'cluster' }, + testK8sModels, + ), + true, + ); + strictEqual( + isClusterScopedRef( + { kind: 'Deployment', name: 'payments-api', namespace: 'payments' }, + testK8sModels, + ), + false, + ); + }); + + it('detects namespaced refs', () => { + strictEqual( + isNamespacedRef( + { kind: 'Deployment', name: 'payments-api', namespace: 'payments' }, + testK8sModels, + ), + true, + ); + strictEqual(isNamespacedRef({ kind: 'Node', name: 'worker-1' }, testK8sModels), false); + }); +}); + +describe('buildResourceConsolePath', () => { + it('builds namespaced pod path', () => { + strictEqual( + buildResourceConsolePath( + { kind: 'Pod', name: 'payments-api', namespace: 'payments' }, + testK8sModels, + ), + '/k8s/ns/payments/pods/payments-api', + ); + }); + + it('builds cluster node path', () => { + strictEqual( + buildResourceConsolePath({ kind: 'Node', name: 'worker-1' }, testK8sModels), + '/k8s/cluster/nodes/worker-1', + ); + }); + + it('builds deployment path', () => { + strictEqual( + buildResourceConsolePath( + { + kind: 'Deployment', + name: 'reporting-service', + namespace: 'shared-services', + }, + testK8sModels, + ), + '/k8s/ns/shared-services/deployments/reporting-service', + ); + }); + + it('builds CRD detail path using the model key', () => { + strictEqual( + buildResourceConsolePath( + { + kind: 'VirtualMachine', + name: 'my-vm', + namespace: 'default', + }, + testK8sModels, + ), + '/k8s/ns/default/kubevirt.io~v1~VirtualMachine/my-vm', + ); + }); + + it('returns null without namespace for namespaced kinds', () => { + strictEqual(buildResourceConsolePath({ kind: 'Pod', name: 'test' }, testK8sModels), null); + }); + + it('builds OpenShift project path when useProjectRoute is set', () => { + strictEqual( + buildResourceConsolePath( + { kind: 'Namespace', name: 'payments', useProjectRoute: true }, + testK8sModels, + ), + '/k8s/cluster/projects/payments', + ); + }); + + it('builds namespace admin path by default', () => { + strictEqual( + buildResourceConsolePath({ kind: 'Namespace', name: 'payments' }, testK8sModels), + '/k8s/cluster/namespaces/payments', + ); }); }); diff --git a/unit-tests/resourceListParsing.test.ts b/unit-tests/resourceListParsing.test.ts new file mode 100644 index 00000000..cbe01552 --- /dev/null +++ b/unit-tests/resourceListParsing.test.ts @@ -0,0 +1,101 @@ +import { describe, it } from 'node:test'; +import { strictEqual } from 'node:assert'; + +import { + extractResourcesFromMcpTableContent, + extractResourcesFromVerticalListing, + getResourceListToolContexts, + hasBulkResourceListTool, + isVerticalListNoiseLine, +} from '../src/resourceListParsing'; +import { isPlausibleResourceName } from '../src/resourceRefs'; + +describe('extractResourcesFromMcpTableContent', () => { + it('parses namespaced core and OpenShift table rows', () => { + const content = `NAMESPACE APIVERSION KIND NAME DATA AGE LABELS +payments v1 ConfigMap api-config 2 5m app=payments +payments apps/v1 Deployment payments-api 3 10m app=payments +payments route.openshift.io/v1 Route payments-route 15m host=payments`; + + const refs = extractResourcesFromMcpTableContent(content); + strictEqual(refs.length, 3); + strictEqual(refs[0].kind, 'ConfigMap'); + strictEqual(refs[0].name, 'api-config'); + strictEqual(refs[0].namespace, 'payments'); + strictEqual(refs[1].kind, 'Deployment'); + strictEqual(refs[2].kind, 'Route'); + strictEqual(refs[2].name, 'payments-route'); + }); + + it('parses cluster-scoped table rows', () => { + const content = `APIVERSION KIND NAME STATUS AGE LABELS +v1 Namespace default Active 3h38m kubernetes.io/metadata.name=default +v1 Node worker-1.example.internal Ready worker 3h17m`; + + const refs = extractResourcesFromMcpTableContent(content); + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Namespace'); + strictEqual(refs[1].kind, 'Node'); + strictEqual(refs[1].name, 'worker-1.example.internal'); + }); +}); + +describe('extractResourcesFromVerticalListing', () => { + it('skips status, age, and replica noise lines', () => { + const refs = extractResourcesFromVerticalListing( + `payments-api +2/2 +Running +3h10m`, + [{ kind: 'Deployment', namespace: 'payments' }], + isPlausibleResourceName, + ); + + strictEqual(refs.length, 1); + strictEqual(refs[0].kind, 'Deployment'); + strictEqual(refs[0].name, 'payments-api'); + strictEqual(refs[0].namespace, 'payments'); + }); +}); + +describe('getResourceListToolContexts', () => { + it('collects contexts for default and OpenShift list tools', () => { + const contexts = getResourceListToolContexts({ + t1: { + args: { apiVersion: 'apps/v1', kind: 'Deployment', namespace: 'payments' }, + content: '', + name: 'resources_list', + status: 'success', + }, + t2: { + args: { apiVersion: 'route.openshift.io/v1', kind: 'Route' }, + content: '', + name: 'resources_list', + status: 'success', + }, + t3: { + args: {}, + content: '', + name: 'namespaces_list', + status: 'success', + }, + }); + + strictEqual(contexts.length, 3); + strictEqual( + hasBulkResourceListTool({ + t1: { args: {}, content: '', name: 'pods_list', status: 'success' }, + }), + true, + ); + }); +}); + +describe('isVerticalListNoiseLine', () => { + it('treats common kubectl column values as noise', () => { + strictEqual(isVerticalListNoiseLine('3h38m'), true); + strictEqual(isVerticalListNoiseLine('2/2'), true); + strictEqual(isVerticalListNoiseLine('Running'), true); + strictEqual(isVerticalListNoiseLine('payments-api'), false); + }); +}); diff --git a/unit-tests/resourceRefs.test.ts b/unit-tests/resourceRefs.test.ts new file mode 100644 index 00000000..bfe3565b --- /dev/null +++ b/unit-tests/resourceRefs.test.ts @@ -0,0 +1,467 @@ +import { describe, it } from 'node:test'; +import { strictEqual } from 'node:assert'; + +import { + buildResourceConsolePath, + extractNodesFromTopContent, + extractResourceRefs, + extractResourcesFromText, + extractResourcesFromToolContent, + isPlausibleResourceName, +} from '../src/resourceRefs'; +import { testK8sModels } from './fixtures/k8sModels'; + +describe('extractResourcesFromText', () => { + it('matches known kinds and ignores unknown PascalCase words', () => { + const refs = extractResourcesFromText( + 'Pod payments-api in namespace payments while OpenShift Lightspeed investigates.', + testK8sModels, + ); + strictEqual(refs.length, 1); + strictEqual(refs[0].kind, 'Pod'); + strictEqual(refs[0].name, 'payments-api'); + strictEqual(refs[0].namespace, 'payments'); + }); + + it('resolves CRD kind names from models', () => { + const refs = extractResourcesFromText( + 'VirtualMachine my-vm in namespace default failed to start.', + testK8sModels, + ); + strictEqual(refs.length, 1); + strictEqual(refs[0].kind, 'kubevirt.io~v1~VirtualMachine'); + }); + + it('ignores alert prose false positives', () => { + const text = `Check the ClusterVersion object and consider clearing spec.channel. +Alertmanager is not configured. MachineConfigPool will never finish updating.`; + const refs = extractResourcesFromText(text, testK8sModels); + strictEqual(refs.length, 0); + }); + + it('parses Name: pod from LLM summary when pods tool ran', () => { + const refs = extractResourceRefs( + { + t1: { + args: { namespace: 'openshift-lightspeed' }, + content: '', + name: 'pods_list_in_namespace', + status: 'success', + }, + }, + 'Name: lightspeed-console-plugin-588df96978-7ngf7\nStatus: Running', + testK8sModels, + ); + strictEqual(refs.length, 1); + strictEqual(refs[0].kind, 'Pod'); + strictEqual(refs[0].name, 'lightspeed-console-plugin-588df96978-7ngf7'); + strictEqual(refs[0].namespace, 'openshift-lightspeed'); + }); +}); + +describe('extractResourceRefs', () => { + it('normalizes refs to model keys', () => { + const refs = extractResourceRefs( + undefined, + 'Deployment reporting-service in namespace shared-services is unhealthy.', + testK8sModels, + ); + strictEqual(refs.length, 1); + strictEqual(refs[0].kind, 'Deployment'); + }); + + it('extracts pods from pods_list_in_namespace table content', () => { + const tableContent = `NAMESPACE APIVERSION KIND NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES LABELS +openshift-lightspeed v1 Pod lightspeed-app-server-6975d49c5c-5rdlk 2/2 Running 0 2m29s 10.130.0.23 crc IP +openshift-lightspeed v1 Pod lightspeed-console-plugin-588df96978-7ngf7 1/1 Running 0 2m29s 10.129.0.20 crc IP `; + + const refs = extractResourceRefs( + { + t1: { + args: { namespace: 'openshift-lightspeed' }, + content: tableContent, + name: 'pods_list_in_namespace', + status: 'success', + }, + }, + undefined, + testK8sModels, + ); + + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Pod'); + strictEqual(refs[0].name, 'lightspeed-app-server-6975d49c5c-5rdlk'); + strictEqual(refs[0].namespace, 'openshift-lightspeed'); + }); + + it('extracts nodes from nodes_top table content', () => { + const tableContent = `NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%) +ip-10-0-110-168.us-west-1.compute.internal 117m 3% 3233Mi 22% +ip-10-0-19-247.us-west-1.compute.internal 710m 20% 10120Mi 69%`; + + const refs = extractResourceRefs( + { + t1: { + args: {}, + content: tableContent, + name: 'nodes_top', + status: 'success', + }, + }, + undefined, + testK8sModels, + ); + + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Node'); + strictEqual(refs[0].name, 'ip-10-0-110-168.us-west-1.compute.internal'); + strictEqual(refs[1].name, 'ip-10-0-19-247.us-west-1.compute.internal'); + }); + + it('extracts nodes from resources_list table content', () => { + const tableContent = `APIVERSION KIND NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME +v1 Node ip-10-0-110-168.us-west-1.compute.internal Ready worker 3h17m Red Hat Enterprise Linux CoreOS 10.0.110.168 +v1 Node ip-10-0-19-247.us-west-1.compute.internal Ready control-plane,master 3h35m Red Hat Enterprise Linux CoreOS 10.0.19.247`; + + const refs = extractResourceRefs( + { + t1: { + args: { apiVersion: 'v1', kind: 'Node' }, + content: tableContent, + name: 'resources_list', + status: 'success', + }, + }, + undefined, + testK8sModels, + ); + + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Node'); + strictEqual(refs[0].name, 'ip-10-0-110-168.us-west-1.compute.internal'); + }); + + it('extracts nodes from resources_list structured content', () => { + const refs = extractResourceRefs( + { + t1: { + args: { apiVersion: 'v1', kind: 'Node' }, + content: '', + name: 'resources_list', + status: 'success', + structuredContent: { + items: [ + { Name: 'ip-10-0-52-226.us-west-1.compute.internal', Status: 'Ready' }, + { Name: 'ip-10-0-82-49.us-west-1.compute.internal', Status: 'Ready' }, + ], + }, + }, + }, + undefined, + testK8sModels, + ); + + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Node'); + strictEqual(refs[1].name, 'ip-10-0-82-49.us-west-1.compute.internal'); + }); + + it('extracts namespaces from namespaces_list table content', () => { + const tableContent = `APIVERSION KIND NAME STATUS AGE LABELS +v1 Namespace default Active 3h38m kubernetes.io/metadata.name=default +v1 Namespace openshift-lightspeed Active 169m pod-security.kubernetes.io/enforce=restricted`; + + const refs = extractResourceRefs( + { + t1: { + args: {}, + content: tableContent, + name: 'namespaces_list', + status: 'success', + }, + }, + undefined, + testK8sModels, + ); + + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Namespace'); + strictEqual(refs[0].name, 'default'); + strictEqual(refs[1].name, 'openshift-lightspeed'); + }); + + it('extracts projects from projects_list with project route', () => { + const tableContent = `APIVERSION KIND NAME STATUS AGE +v1 Project payments Active 3h +v1 Project shared-services Active 1d`; + + const refs = extractResourceRefs( + { + t1: { + args: {}, + content: tableContent, + name: 'projects_list', + status: 'success', + }, + }, + undefined, + testK8sModels, + ); + + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Namespace'); + strictEqual(refs[0].name, 'payments'); + strictEqual(refs[0].useProjectRoute, true); + strictEqual(buildResourceConsolePath(refs[0], testK8sModels), '/k8s/cluster/projects/payments'); + }); + + it('extracts involved objects from events_list YAML content', () => { + const eventsContent = `# The following events (YAML format) were found: +- InvolvedObject: + APIVersion: v1 + Kind: Pod + Name: redhat-operators-2mhcl + Namespace: openshift-redhat-operators + Message: Startup probe failed + Type: Warning`; + + const refs = extractResourceRefs( + { + t1: { + args: {}, + content: eventsContent, + name: 'events_list', + status: 'success', + }, + }, + undefined, + testK8sModels, + ); + + strictEqual(refs.length, 1); + strictEqual(refs[0].kind, 'Pod'); + strictEqual(refs[0].name, 'redhat-operators-2mhcl'); + strictEqual(refs[0].namespace, 'openshift-redhat-operators'); + }); + + it('extracts pod names from prose when pods or events tools ran', () => { + const refs = extractResourceRefs( + { + t1: { args: {}, content: '', name: 'pods_list', status: 'success' }, + t2: { args: {}, content: '', name: 'events_list', status: 'success' }, + }, + 'Events indicate a warning related to the redhat-operators-2mhcl pod.', + testK8sModels, + ); + + strictEqual(refs.length, 1); + strictEqual(refs[0].kind, 'Pod'); + strictEqual(refs[0].name, 'redhat-operators-2mhcl'); + }); + + it('extracts namespaces from LLM vertical listing when namespaces_list ran', () => { + const refs = extractResourceRefs( + { + t1: { + args: {}, + content: '', + name: 'namespaces_list', + status: 'success', + }, + }, + `Here are the OpenShift namespaces in the current cluster: + +default +Active +3h38m +openshift-lightspeed +Active +169m`, + testK8sModels, + ); + + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Namespace'); + strictEqual(refs[0].name, 'default'); + strictEqual(refs[1].name, 'openshift-lightspeed'); + }); + + it('extracts namespaces from namespaces_list YAML array', () => { + const refs = extractResourcesFromToolContent( + `- apiVersion: v1 + kind: Namespace + metadata: + name: default +- apiVersion: v1 + kind: Namespace + metadata: + name: kube-system`, + 'namespaces_list', + ); + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Namespace'); + strictEqual(refs[0].name, 'default'); + strictEqual(refs[1].name, 'kube-system'); + }); + + it('extracts deployments from resources_list table content', () => { + const tableContent = `NAMESPACE APIVERSION KIND NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR +payments apps/v1 Deployment payments-api 2/2 2 2 3h10m payments-api registry/payments:latest app=payments +payments apps/v1 Deployment reporting-service 1/1 1 1 2h5m reporting-service registry/reporting:latest app=reporting`; + + const refs = extractResourceRefs( + { + t1: { + args: { apiVersion: 'apps/v1', kind: 'Deployment', namespace: 'payments' }, + content: tableContent, + name: 'resources_list', + status: 'success', + }, + }, + undefined, + testK8sModels, + ); + + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Deployment'); + strictEqual(refs[0].name, 'payments-api'); + strictEqual(refs[0].namespace, 'payments'); + }); + + it('extracts routes from resources_list table content', () => { + const tableContent = `NAMESPACE APIVERSION KIND NAME HOST/PORT PATH SERVICES PORT TERMINATION WILDCARD AGE LABELS +payments route.openshift.io/v1 Route payments-route payments.example.com / payments-api 8080 edge 15m app=payments`; + + const refs = extractResourceRefs( + { + t1: { + args: { apiVersion: 'route.openshift.io/v1', kind: 'Route' }, + content: tableContent, + name: 'resources_list', + status: 'success', + }, + }, + undefined, + testK8sModels, + ); + + strictEqual(refs.length, 1); + strictEqual(refs[0].kind, 'route.openshift.io~v1~Route'); + strictEqual(refs[0].name, 'payments-route'); + strictEqual(refs[0].namespace, 'payments'); + }); + + it('extracts deployments from LLM vertical listing when resources_list ran', () => { + const refs = extractResourceRefs( + { + t1: { + args: { apiVersion: 'apps/v1', kind: 'Deployment', namespace: 'payments' }, + content: '', + name: 'resources_list', + status: 'success', + }, + }, + `Here are the deployments in the payments namespace: + +payments-api +2/2 +3h10m +reporting-service +1/1 +2h5m`, + testK8sModels, + ); + + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Deployment'); + strictEqual(refs[0].name, 'payments-api'); + strictEqual(refs[1].name, 'reporting-service'); + }); + + it('extracts namespaces from namespaces_list structured content', () => { + const refs = extractResourceRefs( + { + t1: { + args: {}, + content: '', + name: 'namespaces_list', + status: 'success', + structuredContent: { + items: [ + { Name: 'default', Status: 'Active' }, + { Name: 'openshift-lightspeed', Status: 'Active' }, + ], + }, + }, + }, + undefined, + testK8sModels, + ); + + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Namespace'); + strictEqual(refs[1].name, 'openshift-lightspeed'); + }); +}); + +describe('extractResourcesFromToolContent', () => { + it('parses PodList JSON items', () => { + const refs = extractResourcesFromToolContent( + JSON.stringify({ + kind: 'PodList', + items: [ + { + kind: 'Pod', + metadata: { name: 'api', namespace: 'payments' }, + }, + ], + }), + ); + strictEqual(refs.length, 1); + strictEqual(refs[0].name, 'api'); + }); + + it('uses list kind when items omit kind', () => { + const refs = extractResourcesFromToolContent( + JSON.stringify({ + kind: 'PodList', + items: [{ metadata: { name: 'api', namespace: 'payments' } }], + }), + ); + strictEqual(refs.length, 1); + strictEqual(refs[0].kind, 'Pod'); + strictEqual(refs[0].name, 'api'); + }); + + it('parses nodes_top metrics table rows', () => { + const refs = extractResourcesFromToolContent( + `NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%) +worker-1 117m 3% 3233Mi 22%`, + 'nodes_top', + ); + strictEqual(refs.length, 1); + strictEqual(refs[0].kind, 'Node'); + strictEqual(refs[0].name, 'worker-1'); + }); +}); + +describe('extractNodesFromTopContent', () => { + it('skips the header row and deduplicates node names', () => { + const refs = extractNodesFromTopContent( + `NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%) +node-a 100m 1% 1000Mi 10% +node-a 100m 1% 1000Mi 10% +node-b 200m 2% 2000Mi 20%`, + ); + strictEqual(refs.length, 2); + strictEqual(refs[0].name, 'node-a'); + strictEqual(refs[1].name, 'node-b'); + }); +}); + +describe('isPlausibleResourceName', () => { + it('rejects age values such as 5m', () => { + strictEqual(isPlausibleResourceName('5m'), false); + strictEqual(isPlausibleResourceName('72m'), false); + }); +}); diff --git a/unit-tests/resourceStatus.test.ts b/unit-tests/resourceStatus.test.ts new file mode 100644 index 00000000..bb72e380 --- /dev/null +++ b/unit-tests/resourceStatus.test.ts @@ -0,0 +1,227 @@ +import { describe, it } from 'node:test'; +import { strictEqual } from 'node:assert'; + +import { getResourceStatusSummary, isInformativeStatusLabel } from '../src/resourceStatus'; + +describe('getResourceStatusSummary', () => { + it('uses Ready condition for operator CRDs', () => { + const summary = getResourceStatusSummary('FlowCollector', { + status: { + conditions: [{ type: 'Ready', status: 'True', reason: 'Ready' }], + }, + }); + strictEqual(summary.label, 'Ready'); + strictEqual(summary.variant, 'success'); + }); + + it('surfaces Degraded conditions as danger', () => { + const summary = getResourceStatusSummary('FlowCollector', { + status: { + conditions: [{ type: 'Degraded', status: 'True', reason: 'LokiNotReady' }], + }, + }); + strictEqual(summary.label, 'LokiNotReady'); + strictEqual(summary.variant, 'danger'); + }); + + it('uses CSV top-level phase instead of the first condition reason', () => { + const summary = getResourceStatusSummary('ClusterServiceVersion', { + status: { + phase: 'Succeeded', + reason: 'InstallSucceeded', + conditions: [ + { phase: 'Pending', reason: 'RequirementsUnknown' }, + { phase: 'Succeeded', reason: 'InstallSucceeded' }, + ], + }, + }); + strictEqual(summary.label, 'Succeeded (InstallSucceeded)'); + strictEqual(summary.variant, 'success'); + }); + + it('reports CrashLoopBackOff from container state when phase is Running', () => { + const summary = getResourceStatusSummary('Pod', { + status: { + phase: 'Running', + containerStatuses: [ + { + ready: false, + state: { waiting: { reason: 'CrashLoopBackOff' } }, + }, + ], + }, + }); + strictEqual(summary.label, 'CrashLoopBackOff'); + strictEqual(summary.variant, 'danger'); + }); + + it('reports ImagePullBackOff from container waiting state', () => { + const summary = getResourceStatusSummary('Pod', { + status: { + phase: 'Pending', + containerStatuses: [ + { + ready: false, + state: { waiting: { reason: 'ImagePullBackOff' } }, + }, + ], + }, + }); + strictEqual(summary.label, 'ImagePullBackOff'); + strictEqual(summary.variant, 'danger'); + }); + + it('reports ContainerCreating instead of Pending phase', () => { + const summary = getResourceStatusSummary('Pod', { + status: { + phase: 'Pending', + containerStatuses: [ + { + ready: false, + state: { waiting: { reason: 'ContainerCreating' } }, + }, + ], + }, + }); + strictEqual(summary.label, 'ContainerCreating'); + strictEqual(summary.variant, 'warning'); + }); + + it('reports healthy Running pods from phase when containers are ready', () => { + const summary = getResourceStatusSummary('Pod', { + status: { + phase: 'Running', + containerStatuses: [{ ready: true, state: { running: {} } }], + }, + }); + strictEqual(summary.label, 'Running'); + strictEqual(summary.variant, 'success'); + }); + + it('reports Failed phase for failed pods', () => { + const summary = getResourceStatusSummary('Pod', { + status: { phase: 'Failed' }, + }); + strictEqual(summary.label, 'Failed'); + strictEqual(summary.variant, 'danger'); + }); + + it('reports completed jobs from conditions', () => { + const summary = getResourceStatusSummary('Job', { + status: { + succeeded: 1, + conditions: [{ type: 'Complete', status: 'True', reason: 'Complete' }], + }, + }); + strictEqual(summary.label, 'Complete'); + strictEqual(summary.variant, 'success'); + }); + + it('reports failed jobs', () => { + const summary = getResourceStatusSummary('Job', { + status: { + failed: 1, + conditions: [{ type: 'Failed', status: 'True', reason: 'BackoffLimitExceeded' }], + }, + }); + strictEqual(summary.label, 'Failed'); + strictEqual(summary.variant, 'danger'); + }); + + it('reports suspended cronjobs', () => { + const summary = getResourceStatusSummary('CronJob', { + spec: { suspend: true }, + status: {}, + }); + strictEqual(summary.label, 'Suspended'); + strictEqual(summary.variant, 'warning'); + }); + + it('reports PVC phase', () => { + const summary = getResourceStatusSummary('PersistentVolumeClaim', { + status: { phase: 'Bound' }, + }); + strictEqual(summary.label, 'Bound'); + strictEqual(summary.variant, 'success'); + }); + + it('reports namespace phase', () => { + const summary = getResourceStatusSummary('Namespace', { + status: { phase: 'Terminating' }, + }); + strictEqual(summary.label, 'Terminating'); + strictEqual(summary.variant, 'warning'); + }); + + it('reports pending load balancer services', () => { + const summary = getResourceStatusSummary('Service', { + spec: { type: 'LoadBalancer' }, + status: { loadBalancer: {} }, + }); + strictEqual(summary.label, 'Pending'); + strictEqual(summary.variant, 'warning'); + }); + + it('returns em dash for ClusterIP services (type is not status)', () => { + const summary = getResourceStatusSummary('Service', { + spec: { type: 'ClusterIP' }, + }); + strictEqual(summary.label, '—'); + strictEqual(isInformativeStatusLabel(summary.label), false); + }); + + it('reports HPA replica counts', () => { + const summary = getResourceStatusSummary('HorizontalPodAutoscaler', { + status: { currentReplicas: 2, desiredReplicas: 3 }, + }); + strictEqual(summary.label, '2/3 replicas'); + strictEqual(summary.variant, 'warning'); + }); + + it('reports ingress readiness from load balancer status', () => { + const summary = getResourceStatusSummary('Ingress', { + status: { loadBalancer: { ingress: [{ ip: '10.0.0.1' }] } }, + }); + strictEqual(summary.label, 'Ready'); + strictEqual(summary.variant, 'success'); + }); + + it('reports scaled-to-zero workloads as neutral', () => { + const summary = getResourceStatusSummary('Deployment', { + status: { replicas: 0, readyReplicas: 0 }, + }); + strictEqual(summary.label, '0/0 ready'); + strictEqual(summary.variant, 'info'); + }); + + it('reports ready LoadBalancer services', () => { + const summary = getResourceStatusSummary('Service', { + spec: { type: 'LoadBalancer' }, + status: { loadBalancer: { ingress: [{ ip: '10.0.0.1' }] } }, + }); + strictEqual(summary.label, 'LoadBalancer ready'); + strictEqual(summary.variant, 'success'); + }); + + it('returns em dash for status-less resources like ConfigMap', () => { + const summary = getResourceStatusSummary('ConfigMap', { + metadata: { name: 'app-config' }, + }); + strictEqual(summary.label, '—'); + strictEqual(isInformativeStatusLabel(summary.label), false); + }); +}); + +describe('isInformativeStatusLabel', () => { + it('rejects empty and placeholder labels', () => { + strictEqual(isInformativeStatusLabel(''), false); + strictEqual(isInformativeStatusLabel(' '), false); + strictEqual(isInformativeStatusLabel('-'), false); + strictEqual(isInformativeStatusLabel('—'), false); + }); + + it('accepts real status text', () => { + strictEqual(isInformativeStatusLabel('Running'), true); + strictEqual(isInformativeStatusLabel('2/3 ready'), true); + }); +}); diff --git a/webpack.config.ts b/webpack.config.ts index bac34cae..0052c1dc 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -79,7 +79,13 @@ const config: Configuration = { }), new ConsoleRemotePlugin(), new CopyWebpackPlugin({ - patterns: [{ from: path.resolve(__dirname, 'locales'), to: 'locales' }], + patterns: [ + { + context: path.resolve(__dirname, 'locales'), + from: '**/*', + to: 'locales/[path][name][ext]', + }, + ], }), ], devtool: 'source-map',