From e7ef33226ef6b595313e639817fcd607bd597b5a Mon Sep 17 00:00:00 2001 From: Julien Pinsonneau Date: Tue, 30 Jun 2026 14:45:34 +0200 Subject: [PATCH 1/3] guided tour and live cluster widgets --- .gitignore | 1 + Dockerfile.dev | 38 + .../en/plugin__lightspeed-console-plugin.json | 49 ++ package-lock.json | 32 +- src/components/EvidenceTourPanel.tsx | 197 +++++ src/components/EvidenceTourStepTitle.tsx | 53 ++ src/components/GeneralPage.tsx | 736 ++++++++++-------- src/components/GuideMeAction.tsx | 70 ++ src/components/LiveFieldGrid.tsx | 31 + src/components/LivingMetricSparkline.tsx | 64 ++ src/components/LivingResourceCard.tsx | 136 ++++ src/components/LivingResourcesPanel.tsx | 27 + src/components/Popover.tsx | 23 +- src/components/ResourceLiveDetailsView.tsx | 57 ++ src/components/evidence-tour.css | 85 ++ src/components/general-page.css | 7 + src/components/living-response.css | 76 ++ src/consoleNavigation.ts | 19 + src/evidenceTour.ts | 225 ++++++ src/hooks/useConsoleNavigation.ts | 23 + src/hooks/useMessageSources.tsx | 176 +++++ src/livingResponse.ts | 266 +++++++ src/pageContext.ts | 87 ++- src/redux-actions.ts | 19 +- src/redux-reducers.ts | 48 +- src/resourceListParsing.ts | 256 ++++++ src/resourceLiveDetails.ts | 182 +++++ src/resourceLivingMetrics.ts | 182 +++++ src/resourceRefs.ts | 680 ++++++++++++++++ src/resourceStatus.ts | 134 ++++ src/types.ts | 17 + unit-tests/consoleNavigation.test.ts | 12 + unit-tests/evidenceTour.test.ts | 191 +++++ unit-tests/fixtures/k8sModels.ts | 75 ++ unit-tests/livingResponse.test.ts | 617 +++++++++++++++ unit-tests/pageContext.test.ts | 109 ++- unit-tests/redux-reducers.test.ts | 57 ++ unit-tests/resourceListParsing.test.ts | 96 +++ unit-tests/resourceLiveDetails.test.ts | 87 +++ unit-tests/resourceLivingMetrics.test.ts | 91 +++ unit-tests/resourceRefs.test.ts | 421 ++++++++++ unit-tests/resourceStatus.test.ts | 26 + webpack.config.ts | 9 +- 43 files changed, 5411 insertions(+), 376 deletions(-) create mode 100644 Dockerfile.dev create mode 100644 src/components/EvidenceTourPanel.tsx create mode 100644 src/components/EvidenceTourStepTitle.tsx create mode 100644 src/components/GuideMeAction.tsx create mode 100644 src/components/LiveFieldGrid.tsx create mode 100644 src/components/LivingMetricSparkline.tsx create mode 100644 src/components/LivingResourceCard.tsx create mode 100644 src/components/LivingResourcesPanel.tsx create mode 100644 src/components/ResourceLiveDetailsView.tsx create mode 100644 src/components/evidence-tour.css create mode 100644 src/components/living-response.css create mode 100644 src/consoleNavigation.ts create mode 100644 src/evidenceTour.ts create mode 100644 src/hooks/useConsoleNavigation.ts create mode 100644 src/hooks/useMessageSources.tsx create mode 100644 src/livingResponse.ts create mode 100644 src/resourceListParsing.ts create mode 100644 src/resourceLiveDetails.ts create mode 100644 src/resourceLivingMetrics.ts create mode 100644 src/resourceRefs.ts create mode 100644 src/resourceStatus.ts create mode 100644 unit-tests/consoleNavigation.test.ts create mode 100644 unit-tests/evidenceTour.test.ts create mode 100644 unit-tests/fixtures/k8sModels.ts create mode 100644 unit-tests/livingResponse.test.ts create mode 100644 unit-tests/resourceListParsing.test.ts create mode 100644 unit-tests/resourceLiveDetails.test.ts create mode 100644 unit-tests/resourceLivingMetrics.test.ts create mode 100644 unit-tests/resourceRefs.test.ts create mode 100644 unit-tests/resourceStatus.test.ts diff --git a/.gitignore b/.gitignore index 883c3ec7..9a848dff 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ .playwright-mcp CLAUDE.local.md dist/ +dist-test-*/ gui_test_screenshots/ node_modules/ tests/.auth/ diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 00000000..64ba90ae --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,38 @@ +# Local development image: builds only the current (main) plugin variant. +# No git submodules required — use instead of Dockerfile for CRC / single-version clusters. +# +# docker build -f Dockerfile.dev -t quay.io/jpinsonn/lightspeed-console-plugin:dev . +# docker push quay.io/jpinsonn/lightspeed-console-plugin:dev + +FROM registry.access.redhat.com/ubi9/nodejs-22-minimal:latest AS build +USER root +WORKDIR /usr/src/app +COPY package.json package-lock.json ./ +RUN NODE_OPTIONS=--max-old-space-size=4096 npm ci --omit=dev --omit=optional --ignore-scripts --no-fund +COPY console-extensions.json LICENSE tsconfig.json types.d.ts webpack.config.ts ./ +COPY locales ./locales +COPY src ./src +RUN npm run build + +FROM registry.access.redhat.com/ubi9-minimal@sha256:83006d535923fcf1345067873524a3980316f51794f01d8655be55d6e9387183 +USER 0 + +RUN microdnf install -y nginx && microdnf clean all + +COPY --from=build /usr/src/app/dist /builds/main + +RUN mkdir -p /licenses +COPY --from=build /usr/src/app/LICENSE /licenses/LICENSE + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +RUN rm -rf /usr/share/nginx/html && \ + ln -s /tmp/nginx/html /usr/share/nginx/html && \ + mkdir -p /tmp/nginx && \ + chgrp -R 0 /var/log/nginx /var/lib/nginx /tmp/nginx /builds && \ + chmod -R g=u /var/log/nginx /var/lib/nginx /tmp/nginx /builds + +USER 1001 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/locales/en/plugin__lightspeed-console-plugin.json b/locales/en/plugin__lightspeed-console-plugin.json index f5e90018..5d35b3d7 100644 --- a/locales/en/plugin__lightspeed-console-plugin.json +++ b/locales/en/plugin__lightspeed-console-plugin.json @@ -40,6 +40,7 @@ "Edit": "Edit", "email the Red Hat team": "email the Red Hat team", "Erase and start new chat": "Erase and start new chat", + "End tour": "End tour", "Error converting ManagedCluster to YAML: {{e}}": "Error converting ManagedCluster to YAML: {{e}}", "Error converting ManagedClusterInfo to YAML: {{e}}": "Error converting ManagedClusterInfo to YAML: {{e}}", "Error converting to YAML: {{e}}": "Error converting to YAML: {{e}}", @@ -50,6 +51,7 @@ "Error querying OpenShift Lightspeed service": "Error querying OpenShift Lightspeed service", "Error submitting feedback": "Error submitting feedback", "Events": "Events", + "Guided tour": "Guided tour", "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.", @@ -78,6 +80,41 @@ "Kind, Metadata, and Status sections only": "Kind, Metadata, and Status sections only", "Large prompt": "Large prompt", "Leave": "Leave", + "Evidence sources": "Evidence sources", + "Live cluster resources": "Live cluster resources", + "live resource": "live resource", + "live resources": "live resources", + "Next live resource": "Next live resource", + "Previous live resource": "Previous live resource", + "Showing {{shown}} of {{total}} live resources": "Showing {{shown}} of {{total}} live resources", + "Live cluster resource": "Live cluster resource", + "Living metric CPU": "CPU (5m rate)", + "Living metric memory": "Memory working set", + "Living metric replicas": "Available replicas", + "Living metric ready scheduled": "Ready scheduled", + "Living metric storage used": "Storage used", + "Living metric storage capacity": "Storage capacity", + "Living detail ready": "Ready", + "Living detail status": "Status", + "Living detail restarts": "Restarts", + "Living detail age": "Age", + "Living detail IP": "IP", + "Living detail node": "Node", + "Living detail replicas": "Replicas", + "Living detail updated": "Updated", + "Living detail available": "Available", + "Living detail internal IP": "Internal IP", + "Living detail roles": "Roles", + "Living detail version": "Version", + "Living detail phase": "Phase", + "Living detail type": "Type", + "Living detail cluster IP": "Cluster IP", + "Living detail ports": "Ports", + "Living detail host": "Host", + "Living detail path": "Path", + "Living detail TLS": "TLS", + "Living detail data keys": "Data keys", + "Loading live resource status": "Loading live resource status", "Loading MCP App...": "Loading MCP App...", "Logs": "Logs", "MCP App Error": "MCP App Error", @@ -87,6 +124,8 @@ "Minimize": "Minimize", "Most recent {{lines}} lines": "Most recent {{lines}} lines", "Most recent {{numEvents}} events": "Most recent {{numEvents}} events", + "Next": "Next", + "Next source": "Next source", "No events": "No events", "No logs found": "No logs found", "No output returned": "No output returned", @@ -97,6 +136,9 @@ "Only one event available for this resource.": "Only one event available for this resource.", "OpenShift Lightspeed authentication failed. Contact your system administrator for more information.": "OpenShift Lightspeed authentication failed. Contact your system administrator for more information.", "OpenShift Lightspeed chat history": "OpenShift Lightspeed chat history", + "OpenShift Lightspeed guided tour": "OpenShift Lightspeed guided tour", + "Open in console": "Open in console", + "Unable to start guided tour": "Unable to start guided tour", "OpenShift Lightspeed is now available to help you with your OpenShift questions and tasks. Try asking about deployments, troubleshooting, best practices, or any other OpenShift-related topics. This notice will disappear once you minimize the chat.": "OpenShift Lightspeed is now available to help you with your OpenShift questions and tasks. Try asking about deployments, troubleshooting, best practices, or any other OpenShift-related topics. This notice will disappear once you minimize the chat.", "OpenShift Lightspeed uses AI technology to help answer your questions. Do not include personal information or other sensitive information in your input. Interactions may be used to improve Red Hat's products or services.": "OpenShift Lightspeed uses AI technology to help answer your questions. Do not include personal information or other sensitive information in your input. Interactions may be used to improve Red Hat's products or services.", "pending": "pending", @@ -104,6 +146,8 @@ "Pod": "Pod", "Preview attachment": "Preview attachment", "Preview attachment - modified": "Preview attachment - modified", + "Previous": "Previous", + "Previous source": "Previous source", "Red Hat OpenShift Lightspeed": "Red Hat OpenShift Lightspeed", "Refresh": "Refresh", "Reject": "Reject", @@ -111,9 +155,13 @@ "Revert to original": "Revert to original", "Review required": "Review required", "Save": "Save", + "Guide me": "Guide me", + "source": "source", + "sources": "sources", "Silence": "Silence", "Status": "Status", "Stay": "Stay", + "Step {{current}} of {{total}}": "Step {{current}} of {{total}}", "Structured content": "Structured content", "Submit": "Submit", "The following output was generated when running <2>{{name}} with arguments <5>{{argsFormatted}}.": "The following output was generated when running <2>{{name}} with arguments <5>{{argsFormatted}}.", @@ -125,6 +173,7 @@ "Tool output": "Tool output", "Total size of attachments exceeds {{max}} characters.": "Total size of attachments exceeds {{max}} characters.", "Troubleshooting": "Troubleshooting", + "Unable to watch resource": "Unable to watch resource", "UI resource": "UI resource", "Upload from computer": "Upload from computer", "Uploaded file is not valid YAML": "Uploaded file is not valid YAML", diff --git a/package-lock.json b/package-lock.json index 4f614ea0..b2926860 100644 --- a/package-lock.json +++ b/package-lock.json @@ -115,6 +115,7 @@ "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -484,6 +485,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -532,6 +534,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1322,6 +1325,7 @@ "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", "license": "MIT", + "peer": true, "dependencies": { "@monaco-editor/loader": "^1.5.0" }, @@ -2160,6 +2164,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2193,6 +2198,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2322,6 +2328,7 @@ "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", @@ -2825,6 +2832,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3473,6 +3481,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4232,6 +4241,7 @@ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" @@ -4958,6 +4968,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5014,6 +5025,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -5891,6 +5903,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6695,6 +6708,7 @@ } ], "license": "MIT", + "peer": true, "peerDependencies": { "typescript": "^5 || ^6" }, @@ -7692,6 +7706,7 @@ "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -9631,6 +9646,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9789,6 +9805,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -10055,6 +10072,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -10067,6 +10085,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -10097,6 +10116,7 @@ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.8.tgz", "integrity": "sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", @@ -10130,6 +10150,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -10153,6 +10174,7 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", "license": "MIT", + "peer": true, "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" @@ -10742,6 +10764,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -11778,6 +11801,7 @@ "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -12150,6 +12174,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12357,7 +12382,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsyringe": { "version": "4.10.0", @@ -12504,6 +12530,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13041,6 +13068,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.107.2.tgz", "integrity": "sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", @@ -13165,6 +13193,7 @@ "integrity": "sha512-4wZtCquSuv9CKX8oybo+mqxtxZqWz47uM1Ch94lxowBztOhWCbhqvRbfC/mODOwxgV2brY+JGZpHq58/SuVFYg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/bonjour": "^3.5.13", "@types/connect-history-api-fallback": "^1.5.4", @@ -13556,6 +13585,7 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/components/EvidenceTourPanel.tsx b/src/components/EvidenceTourPanel.tsx new file mode 100644 index 00000000..53a33d61 --- /dev/null +++ b/src/components/EvidenceTourPanel.tsx @@ -0,0 +1,197 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import { + Alert, + Button, + Content, + Flex, + FlexItem, + Title, +} from '@patternfly/react-core'; +import { AngleLeftIcon, AngleRightIcon } from '@patternfly/react-icons'; + +import { useConsoleNavigation } from '../hooks/useConsoleNavigation'; +import { useK8sModels } from '@openshift-console/dynamic-plugin-sdk'; +import { evidenceTourClose, evidenceTourNext, evidenceTourPrev } from '../redux-actions'; +import { State } from '../redux-reducers'; +import { EvidenceTourStep, EvidenceTourState } from '../types'; +import LivingResourceCard from './LivingResourceCard'; +import EvidenceTourStepTitle from './EvidenceTourStepTitle'; + +import './evidence-tour.css'; +import './living-response.css'; + +type EvidenceTourPanelProps = { + 'aria-label': string; +}; + +const EvidenceTourPanel: React.FC = ({ 'aria-label': ariaLabel }) => { + const { t } = useTranslation('plugin__lightspeed-console-plugin'); + const dispatch = useDispatch(); + const navigate = useConsoleNavigation(); + const [k8sModels] = useK8sModels(); + + const tour = useSelector((s: State) => s.plugins?.ols?.get('evidenceTour')) as + | EvidenceTourState + | undefined; + + const { currentIndex, isActive, steps } = tour ?? { + chatEntryId: null, + currentIndex: 0, + isActive: false, + steps: [] as EvidenceTourStep[], + }; + + const currentStep = steps[currentIndex]; + const isFirst = currentIndex <= 0; + const isLast = currentIndex >= steps.length - 1; + const resolvedModels = React.useMemo(() => k8sModels ?? {}, [k8sModels]); + const navigateRef = React.useRef(navigate); + navigateRef.current = navigate; + const navigatedStepIndexRef = React.useRef(undefined); + + React.useEffect(() => { + if (!isActive) { + navigatedStepIndexRef.current = undefined; + return; + } + if (!currentStep?.path) { + return; + } + if (navigatedStepIndexRef.current === currentIndex) { + return; + } + + navigatedStepIndexRef.current = currentIndex; + const frame = window.requestAnimationFrame(() => { + navigateRef.current(currentStep.path); + }); + + return () => window.cancelAnimationFrame(frame); + }, [currentIndex, currentStep?.path, isActive]); + + const onOpenResource = React.useCallback( + (path: string) => { + navigate(path); + }, + [navigate], + ); + + const onEndTour = React.useCallback(() => { + dispatch(evidenceTourClose()); + }, [dispatch]); + + const onNext = React.useCallback(() => { + dispatch(evidenceTourNext()); + }, [dispatch]); + + const onPrev = React.useCallback(() => { + dispatch(evidenceTourPrev()); + }, [dispatch]); + + if (!isActive) { + return null; + } + + if (!currentStep || steps.length === 0) { + return ( +
+ + + +
+ ); + } + + const showLiveResource = Boolean(currentStep.resourceRef); + const showNarration = !showLiveResource && currentStep.narration; + + return ( +
+
+ + {t('Guided tour')} + +

+ {t('Step {{current}} of {{total}}', { current: currentIndex + 1, total: steps.length })} +

+ + {showLiveResource && currentStep.resourceRef && ( + + )} + {showNarration && ( + + {currentStep.narration} + + )} +
+ + + + + + + + + + + +
+ ); +}; + +export default EvidenceTourPanel; diff --git a/src/components/EvidenceTourStepTitle.tsx b/src/components/EvidenceTourStepTitle.tsx new file mode 100644 index 00000000..7f676182 --- /dev/null +++ b/src/components/EvidenceTourStepTitle.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import { Button, Title } from '@patternfly/react-core'; + +import { getModelKindName, K8sModelRef } from '../pageContext'; +import { ResourceRef } from '../resourceRefs'; + +type EvidenceTourStepTitleProps = { + fallbackLabel: string; + k8sModels: Record; + onOpenResource: (path: string) => void; + path: string; + resourceRef?: ResourceRef; +}; + +const EvidenceTourStepTitle: React.FC = ({ + fallbackLabel, + k8sModels, + onOpenResource, + path, + resourceRef, +}) => { + if (!resourceRef) { + return ( + + {fallbackLabel} + + ); + } + + const kindName = getModelKindName(resourceRef.kind, k8sModels); + const namespaceSuffix = resourceRef.namespace ? ` (${resourceRef.namespace})` : ''; + + return ( + + {kindName}/ + <Button + className="ols-plugin__guided-tour-resource-link" + data-test="ols-plugin__evidence-tour-resource-link" + isInline + onClick={(event) => { + event.preventDefault(); + onOpenResource(path); + }} + variant="link" + > + {resourceRef.name} + </Button> + {namespaceSuffix} + + ); +}; + +export default EvidenceTourStepTitle; diff --git a/src/components/GeneralPage.tsx b/src/components/GeneralPage.tsx index 62e9ae1d..1cb0f91e 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,10 +41,13 @@ 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 { useBoolean } from '../hooks/useBoolean'; import { useFirstTimeUser } from '../hooks/useFirstTimeUser'; import { useIsDarkTheme } from '../hooks/useIsDarkTheme'; +import { useMessageSources } from '../hooks/useMessageSources'; +import { compactResponseForLivingResources } from '../livingResponse'; import { attachmentsClear, chatHistoryClear, @@ -55,14 +58,17 @@ 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 EvidenceTourPanel from './EvidenceTourPanel'; import ImportAction from './ImportAction'; +import LivingResourcesPanel from './LivingResourcesPanel'; import NewChatModal from './NewChatModal'; import Prompt from './Prompt'; import ReadinessAlert from './ReadinessAlert'; import ResponseTools from './ResponseTools'; +import GuideMeAction from './GuideMeAction'; import ToolApproval from './ToolApproval'; import WelcomeNotice from './WelcomeNotice'; @@ -84,15 +90,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 +121,346 @@ 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 messageSources = useMessageSources({ + enabled: isAiEntry && !entry.isStreaming && !entry.error && modelsLoaded, + k8sModels, + references: isAiEntry ? entry.references : undefined, + responseText: isAiEntry ? entry.text : undefined, + tools: aiTools, + }); + const displayContent = + isAiEntry && entry.text + ? (compactResponseForLivingResources(entry.text, aiTools, k8sModels) ?? entry.text) + : 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, + }, + }), + }; + + let 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: ( + <> + {messageSources.clusterSources && ( + + )} + {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 - } - /> - ); - } + {entry.tools && } + {messageSources.showGuide && ( + + )} + {messageSources.docSources.length > 0 && ( +
+ {messageSources.docSources.map((doc) => ( + + {doc.title} + + ))} +
+ )} + + ), + 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 + } + /> + ); + } - 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 = { @@ -472,6 +508,7 @@ const PrivacyAlert: React.FC = () => { type GeneralPageProps = { ariaLabel: string; className: string; + isExpanded?: boolean; onClose: () => void; onCollapse?: () => void; onExpand?: () => void; @@ -480,6 +517,7 @@ type GeneralPageProps = { const GeneralPage: React.FC = ({ ariaLabel, className, + isExpanded = false, onClose, onCollapse, onExpand, @@ -496,10 +534,17 @@ const GeneralPage: React.FC = ({ const [authStatus] = useAuth(); const [isFirstTimeUser] = useFirstTimeUser(); + const [k8sModels, modelsInFlight] = useK8sModels(); + const modelsLoaded = modelsInFlight === false && !!k8sModels; + const resolvedModels = React.useMemo(() => k8sModels ?? {}, [k8sModels]); const [isNewChatModalOpen, , openNewChatModal, closeNewChatModal] = useBoolean(false); const [isCopied, , setCopied, setNotCopied] = useBoolean(false); + const isGuidedTourActive: boolean = useSelector( + (s: State) => s.plugins?.ols?.get('evidenceTour')?.isActive === true, + ); + const chatHistoryEndRef = React.useRef(null); const scrollIntoView = React.useCallback((behavior: ScrollBehavior = 'smooth') => { @@ -555,9 +600,9 @@ const GeneralPage: React.FC = ({ return ( @@ -591,7 +636,7 @@ const GeneralPage: React.FC = ({ )} - {onExpand && ( + {onExpand && !isExpanded && ( + ); +}; + +export default GuideMeAction; diff --git a/src/components/LiveFieldGrid.tsx b/src/components/LiveFieldGrid.tsx new file mode 100644 index 00000000..7ac199e9 --- /dev/null +++ b/src/components/LiveFieldGrid.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; + +export type LiveFieldItem = { + id: string; + label: React.ReactNode; + value: React.ReactNode; +}; + +type LiveFieldGridProps = { + className?: string; + items: LiveFieldItem[]; +}; + +const LiveFieldGrid: React.FC = ({ className, items }) => { + if (items.length === 0) { + return null; + } + + return ( +
+ {items.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ ); +}; + +export default LiveFieldGrid; diff --git a/src/components/LivingMetricSparkline.tsx b/src/components/LivingMetricSparkline.tsx new file mode 100644 index 00000000..71810803 --- /dev/null +++ b/src/components/LivingMetricSparkline.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import { PrometheusResponse } from '@openshift-console/dynamic-plugin-sdk'; + +type SparklineProps = { + height?: number; + response: PrometheusResponse | undefined; + width?: number; +}; + +const extractMetricValues = (response: PrometheusResponse | undefined): number[] => { + const series = response?.data?.result?.[0]; + if (!series) { + return []; + } + if (Array.isArray(series.values)) { + return series.values + .map(([, value]) => parseFloat(value)) + .filter((value) => Number.isFinite(value)); + } + if (Array.isArray(series.value)) { + const value = parseFloat(series.value[1]); + return Number.isFinite(value) ? [value] : []; + } + return []; +}; + +const LivingMetricSparkline: React.FC = ({ + height = 36, + response, + width = 140, +}) => { + const values = React.useMemo(() => extractMetricValues(response), [response]); + + if (values.length === 0) { + return null; + } + + const min = Math.min(...values); + const max = Math.max(...values); + const range = max - min || 1; + const pointCount = Math.max(values.length - 1, 1); + const points = values + .map((value, index) => { + const x = (index / pointCount) * width; + const y = height - ((value - min) / range) * (height - 4) - 2; + return `${x},${y}`; + }) + .join(' '); + + return ( + + ); +}; + +export default LivingMetricSparkline; diff --git a/src/components/LivingResourceCard.tsx b/src/components/LivingResourceCard.tsx new file mode 100644 index 00000000..dfde8e21 --- /dev/null +++ b/src/components/LivingResourceCard.tsx @@ -0,0 +1,136 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + K8sResourceKind, + PrometheusEndpoint, + useK8sWatchResource, + usePrometheusPoll, +} from '@openshift-console/dynamic-plugin-sdk'; +import { Spinner, Stack, StackItem } from '@patternfly/react-core'; + +import { buildLivingMetrics } from '../livingResponse'; +import { LivingMetricDef } from '../resourceLivingMetrics'; +import { getModelKindName, K8sModelRef } from '../pageContext'; +import { ResourceRef } from '../resourceRefs'; +import { getResourceLiveDetails } from '../resourceLiveDetails'; +import { getResourceStatusSummaryForRef } from '../resourceStatus'; +import LiveFieldGrid from './LiveFieldGrid'; +import LivingMetricSparkline from './LivingMetricSparkline'; +import ResourceLiveDetailsView from './ResourceLiveDetailsView'; + +const THIRTY_MINUTES_MS = 30 * 60 * 1000; + +type LivingMetricValueProps = { + namespace?: string; + query: string; +}; + +const LivingMetricValue: React.FC = ({ namespace, query }) => { + const [response, loaded, error] = usePrometheusPoll({ + endpoint: PrometheusEndpoint.QUERY_RANGE, + namespace, + query, + samples: 30, + timespan: THIRTY_MINUTES_MS, + }); + + if (!loaded && !error) { + return ; + } + if (loaded && !error && response) { + return ; + } + return ; +}; + +type LivingMetricFieldsProps = { + metrics: LivingMetricDef[]; + prometheusNamespace?: string; +}; + +const LivingMetricFields: React.FC = ({ + metrics, + prometheusNamespace, +}) => { + const { t } = useTranslation('plugin__lightspeed-console-plugin'); + + return ( + ({ + id: metric.id, + label: t(metric.labelKey), + value: , + }))} + /> + ); +}; + +type LivingResourceCardProps = { + k8sModels: Record; + onUnavailable?: () => void; + resourceRef: ResourceRef; +}; + +const LivingResourceCard: React.FC = ({ + k8sModels, + onUnavailable, + resourceRef, +}) => { + const { t } = useTranslation('plugin__lightspeed-console-plugin'); + const model = k8sModels[resourceRef.kind]; + + const watchProps = model?.namespaced + ? { + isList: false as const, + kind: resourceRef.kind, + name: resourceRef.name, + namespace: resourceRef.namespace, + } + : { + isList: false as const, + kind: resourceRef.kind, + name: resourceRef.name, + }; + + const [resource, loaded, loadError] = useK8sWatchResource(watchProps); + + React.useEffect(() => { + if (loadError || (loaded && !resource)) { + onUnavailable?.(); + } + }, [loadError, loaded, onUnavailable, resource]); + + const metrics = React.useMemo( + () => buildLivingMetrics(resourceRef, k8sModels), + [k8sModels, resourceRef], + ); + const kindName = getModelKindName(resourceRef.kind, k8sModels); + const status = getResourceStatusSummaryForRef(resourceRef.kind, k8sModels, resource); + const detailFields = getResourceLiveDetails(kindName, resource); + const prometheusNamespace = kindName === 'Namespace' ? resourceRef.name : resourceRef.namespace; + + if (loadError || (loaded && !resource)) { + return null; + } + + return ( +
+ {!loaded && } + {loaded && ( + + + + + {metrics.length > 0 && ( + + + + )} + + )} +
+ ); +}; + +export default LivingResourceCard; diff --git a/src/components/LivingResourcesPanel.tsx b/src/components/LivingResourcesPanel.tsx new file mode 100644 index 00000000..a83b02e3 --- /dev/null +++ b/src/components/LivingResourcesPanel.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { SourcesCard, type SourcesCardProps } from '@patternfly/chatbot'; + +import './living-response.css'; + +type LivingResourcesPanelProps = { + overflow?: { shown: number; total: number }; + sources: SourcesCardProps; +}; + +const LivingResourcesPanel: React.FC = ({ overflow, sources }) => { + const { t } = useTranslation('plugin__lightspeed-console-plugin'); + + return ( +
+ {overflow && ( +

+ {t('Showing {{shown}} of {{total}} live resources', overflow)} +

+ )} + +
+ ); +}; + +export default LivingResourcesPanel; diff --git a/src/components/Popover.tsx b/src/components/Popover.tsx index 25a8e16a..19a6a92e 100644 --- a/src/components/Popover.tsx +++ b/src/components/Popover.tsx @@ -84,21 +84,14 @@ const Popover: React.FC = () => { return isOpen ? ( <> - {isExpanded ? ( - - ) : ( - - )} + + ) : ( + {entry.title} + )} + {entry.detail &&

{entry.detail}

} + + + ); +}; + +type ChangeTimelinePanelProps = { + anchor: TimelineAnchor; + embedded?: boolean; + k8sModels: Record; +}; + +const ChangeTimelinePanel: React.FC = ({ + anchor, + embedded = false, + k8sModels, +}) => { + const { t } = useTranslation('plugin__lightspeed-console-plugin'); + const navigate = useConsoleNavigation(); + const { entries, error, loaded } = useChangeTimelineData(anchor, k8sModels); + + return ( +
+
+

{t('Change timeline')}

+

+ {t('Timeline for {{target}} (last 2 hours)', { + target: formatTimelineAnchorLabel(anchor, k8sModels), + })} +

+
+ + {!loaded && } + {error && ( +

+ {t('Failed to load change timeline')}: {error} +

+ )} + {loaded && !error && entries.length === 0 && ( +

{t('No recent changes found')}

+ )} + {loaded && entries.length > 0 && ( +
    + {entries.map((entry) => ( + + ))} +
+ )} +
+ ); +}; + +export default ChangeTimelinePanel; diff --git a/src/components/EvidenceTourPanel.tsx b/src/components/EvidenceTourPanel.tsx index 53a33d61..9f0be596 100644 --- a/src/components/EvidenceTourPanel.tsx +++ b/src/components/EvidenceTourPanel.tsx @@ -16,10 +16,11 @@ import { useK8sModels } from '@openshift-console/dynamic-plugin-sdk'; import { evidenceTourClose, evidenceTourNext, evidenceTourPrev } from '../redux-actions'; import { State } from '../redux-reducers'; import { EvidenceTourStep, EvidenceTourState } from '../types'; -import LivingResourceCard from './LivingResourceCard'; import EvidenceTourStepTitle from './EvidenceTourStepTitle'; +import LivingResourceCard from './LivingResourceCard'; import './evidence-tour.css'; +import './change-timeline.css'; import './living-response.css'; type EvidenceTourPanelProps = { @@ -141,7 +142,11 @@ const EvidenceTourPanel: React.FC = ({ 'aria-label': ari resourceRef={currentStep.resourceRef} /> {showLiveResource && currentStep.resourceRef && ( - + )} {showNarration && ( diff --git a/src/components/GeneralPage.tsx b/src/components/GeneralPage.tsx index 1cb0f91e..e043c440 100644 --- a/src/components/GeneralPage.tsx +++ b/src/components/GeneralPage.tsx @@ -46,8 +46,10 @@ import { AuthStatus, getRequestInitWithAuthHeader, useAuth } from '../hooks/useA import { useBoolean } from '../hooks/useBoolean'; import { useFirstTimeUser } from '../hooks/useFirstTimeUser'; import { useIsDarkTheme } from '../hooks/useIsDarkTheme'; +import { useChangeTimeline } from '../hooks/useChangeTimeline'; import { useMessageSources } from '../hooks/useMessageSources'; import { compactResponseForLivingResources } from '../livingResponse'; +import { resourceRefKey } from '../resourceRefs'; import { attachmentsClear, chatHistoryClear, @@ -61,6 +63,7 @@ import { State } from '../redux-reducers'; import { Attachment, ChatEntry, Tool } from '../types'; import AttachmentLabel from './AttachmentLabel'; import AttachmentsSizeAlert from './AttachmentsSizeAlert'; +import ChangeTimelinePanel from './ChangeTimelinePanel'; import EvidenceTourPanel from './EvidenceTourPanel'; import ImportAction from './ImportAction'; import LivingResourcesPanel from './LivingResourcesPanel'; @@ -174,11 +177,23 @@ const ChatHistoryEntry = React.memo( const [isDarkTheme] = useIsDarkTheme(); const isAiEntry = entry?.who === 'ai'; + const changeTimeline = useChangeTimeline( + isAiEntry && !entry.isStreaming && !entry.error && modelsLoaded, + query, + aiTools, + isAiEntry ? entry.text : undefined, + k8sModels, + ); + const timelineExpandKey = + changeTimeline.showTimeline && changeTimeline.anchor + ? resourceRefKey(changeTimeline.anchor) + : undefined; const messageSources = useMessageSources({ enabled: isAiEntry && !entry.isStreaming && !entry.error && modelsLoaded, k8sModels, references: isAiEntry ? entry.references : undefined, responseText: isAiEntry ? entry.text : undefined, + timelineExpandKey, tools: aiTools, }); const displayContent = @@ -302,6 +317,11 @@ const ChatHistoryEntry = React.memo( sources={messageSources.clusterSources} /> )} + {changeTimeline.showTimeline && + changeTimeline.anchor && + !messageSources.clusterSources && ( + + )} {entry.error && ( ; onUnavailable?: () => void; resourceRef: ResourceRef; + showTimeline?: boolean; }; const LivingResourceCard: React.FC = ({ k8sModels, onUnavailable, resourceRef, + showTimeline = false, }) => { const { t } = useTranslation('plugin__lightspeed-console-plugin'); const model = k8sModels[resourceRef.kind]; @@ -127,6 +131,11 @@ const LivingResourceCard: React.FC = ({ )} + {showTimeline && isTimelineEligibleAnchor(resourceRef, k8sModels) && ( + + + + )} )} diff --git a/src/components/LivingResourceSourceBody.tsx b/src/components/LivingResourceSourceBody.tsx new file mode 100644 index 00000000..b0299001 --- /dev/null +++ b/src/components/LivingResourceSourceBody.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; + +import { K8sModelRef, ResourceRef } from '../pageContext'; +import LivingResourceCard from './LivingResourceCard'; +import LivingResourceTimelineToggle from './LivingResourceTimelineToggle'; + +type LivingResourceSourceBodyProps = { + k8sModels: Record; + onUnavailable?: () => void; + resourceRef: ResourceRef; + timelineDefaultExpanded?: boolean; +}; + +const LivingResourceSourceBody: React.FC = ({ + k8sModels, + onUnavailable, + resourceRef, + timelineDefaultExpanded = false, +}) => ( +
+ + +
+); + +export default LivingResourceSourceBody; diff --git a/src/components/LivingResourceTimelineToggle.tsx b/src/components/LivingResourceTimelineToggle.tsx new file mode 100644 index 00000000..f2d86d7e --- /dev/null +++ b/src/components/LivingResourceTimelineToggle.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@patternfly/react-core'; +import { HistoryIcon } from '@patternfly/react-icons'; + +import { isTimelineEligibleAnchor } from '../changeTimeline'; +import { K8sModelRef, ResourceRef } from '../pageContext'; +import ChangeTimelinePanel from './ChangeTimelinePanel'; + +import './change-timeline.css'; + +type LivingResourceTimelineToggleProps = { + defaultExpanded?: boolean; + embedded?: boolean; + k8sModels: Record; + resourceRef: ResourceRef; +}; + +const LivingResourceTimelineToggle: React.FC = ({ + defaultExpanded = false, + embedded = true, + k8sModels, + resourceRef, +}) => { + const { t } = useTranslation('plugin__lightspeed-console-plugin'); + const [expanded, setExpanded] = React.useState(defaultExpanded); + + React.useEffect(() => { + setExpanded(defaultExpanded); + }, [defaultExpanded, resourceRef.kind, resourceRef.name, resourceRef.namespace]); + + if (!isTimelineEligibleAnchor(resourceRef, k8sModels)) { + return null; + } + + return ( +
+ + {expanded && ( + + )} +
+ ); +}; + +export default LivingResourceTimelineToggle; diff --git a/src/components/change-timeline.css b/src/components/change-timeline.css new file mode 100644 index 00000000..6a5e9102 --- /dev/null +++ b/src/components/change-timeline.css @@ -0,0 +1,101 @@ +.ols-plugin__change-timeline { + border-top: var(--pf-t--global--border--width--regular) solid + var(--pf-t--global--border--color--default); + margin-top: var(--pf-t--global--spacer--sm); + max-width: 100%; + min-width: 0; + padding-top: var(--pf-t--global--spacer--sm); +} + +.ols-plugin__change-timeline-header { + display: flex; + flex-direction: column; + gap: var(--pf-t--global--spacer--xs); + margin-bottom: var(--pf-t--global--spacer--sm); +} + +.ols-plugin__change-timeline-title { + font-size: var(--pf-t--global--font--size--body--default); + font-weight: var(--pf-t--global--font--weight--body--bold); + margin: 0; +} + +.ols-plugin__change-timeline-subtitle { + color: var(--pf-t--global--text--color--subtle); + font-size: var(--pf-t--global--font--size--body--sm); + margin: 0; +} + +.ols-plugin__change-timeline-list { + display: flex; + flex-direction: column; + gap: var(--pf-t--global--spacer--sm); + list-style: none; + margin: 0; + padding: 0; +} + +.ols-plugin__change-timeline-item { + display: grid; + gap: var(--pf-t--global--spacer--xs) var(--pf-t--global--spacer--sm); + grid-template-columns: minmax(4.5rem, auto) auto 1fr; + min-width: 0; +} + +.ols-plugin__change-timeline-time { + color: var(--pf-t--global--text--color--subtle); + font-size: var(--pf-t--global--font--size--body--sm); + white-space: nowrap; +} + +.ols-plugin__change-timeline-body { + display: flex; + flex-direction: column; + gap: var(--pf-t--global--spacer--xs); + min-width: 0; +} + +.ols-plugin__change-timeline-entry-title { + font-size: var(--pf-t--global--font--size--body--default); + text-align: left; +} + +.ols-plugin__change-timeline-detail { + color: var(--pf-t--global--text--color--subtle); + font-size: var(--pf-t--global--font--size--body--sm); + margin: 0; + overflow-wrap: anywhere; +} + +.ols-plugin__change-timeline--embedded { + border-top: none; + margin-top: var(--pf-t--global--spacer--xs); + padding-top: 0; +} + +.ols-plugin__living-resource-preview { + display: flex; + flex-direction: column; + gap: var(--pf-t--global--spacer--xs); + max-width: 100%; + min-width: 0; +} + +.ols-plugin__living-resource-timeline { + display: flex; + flex-direction: column; + gap: var(--pf-t--global--spacer--xs); + min-width: 0; +} + +.ols-plugin__living-resource-timeline-toggle { + align-self: flex-start; + padding-left: 0; +} + +.ols-plugin__change-timeline-empty, +.ols-plugin__change-timeline-error { + color: var(--pf-t--global--text--color--subtle); + font-size: var(--pf-t--global--font--size--body--sm); + margin: 0; +} diff --git a/src/components/evidence-tour.css b/src/components/evidence-tour.css index d3b2cc4d..a9464ef8 100644 --- a/src/components/evidence-tour.css +++ b/src/components/evidence-tour.css @@ -18,6 +18,16 @@ overflow-y: auto; } +.ols-plugin__guided-tour-content .ols-plugin__living-resource, +.ols-plugin__guided-tour-content .ols-plugin__change-timeline { + flex-shrink: 0; + overflow: visible; +} + +.ols-plugin__guided-tour-content .ols-plugin__living-resource { + padding-top: 0; +} + .ols-plugin__guided-tour-heading { margin-bottom: 0; } @@ -82,4 +92,5 @@ flex: 1 1 auto; flex-direction: column; min-height: 0; + overflow: hidden; } diff --git a/src/hooks/useChangeTimeline.ts b/src/hooks/useChangeTimeline.ts new file mode 100644 index 00000000..f2e1ab63 --- /dev/null +++ b/src/hooks/useChangeTimeline.ts @@ -0,0 +1,39 @@ +import * as React from 'react'; + +import { + resolveTimelineAnchor, + shouldShowChangeTimeline, + TimelineAnchor, +} from '../changeTimeline'; +import { useLocationContext } from './useLocationContext'; +import { K8sModelRef } from '../pageContext'; +import { Tool } from '../types'; + +export const useChangeTimeline = ( + enabled: boolean, + query: string | undefined, + tools: Record | undefined, + responseText: string | undefined, + k8sModels: Record, +): { anchor: TimelineAnchor | null; showTimeline: boolean } => { + const [pageKind, pageName, pageNamespace] = useLocationContext(); + + const anchor = React.useMemo( + () => + enabled + ? resolveTimelineAnchor( + pageKind, + pageName, + pageNamespace, + tools, + responseText, + k8sModels, + ) + : null, + [enabled, k8sModels, pageKind, pageName, pageNamespace, responseText, tools], + ); + + const showTimeline = enabled && shouldShowChangeTimeline(query, tools, anchor); + + return { anchor, showTimeline }; +}; diff --git a/src/hooks/useChangeTimelineData.ts b/src/hooks/useChangeTimelineData.ts new file mode 100644 index 00000000..8e3f84d7 --- /dev/null +++ b/src/hooks/useChangeTimelineData.ts @@ -0,0 +1,159 @@ +import * as React from 'react'; +import { + consoleFetchJSON, + K8sResourceKind, + Selector, + useK8sWatchResource, +} from '@openshift-console/dynamic-plugin-sdk'; + +import { + buildEventFetchTargets, + ChangeTimelineEntry, + DEFAULT_TIMELINE_WINDOW_MS, + eventToTimelineEntry, + eventsListPath, + K8sEventLike, + mergeTimelineEntries, + replicaSetToTimelineEntry, + TimelineAnchor, +} from '../changeTimeline'; +import { getRequestInitWithAuthHeader } from '../hooks/useAuth'; +import { getModelKindName, K8sModelRef } from '../pageContext'; + +type EventListResponse = { + items?: K8sEventLike[]; +}; + +const isDeploymentLike = (kindName?: string): boolean => + kindName === 'Deployment' || kindName === 'DeploymentConfig'; + +export const useChangeTimelineData = ( + anchor: TimelineAnchor | null, + k8sModels: Record, + windowMs: number = DEFAULT_TIMELINE_WINDOW_MS, +): { entries: ChangeTimelineEntry[]; error?: string; loaded: boolean } => { + const [entries, setEntries] = React.useState([]); + const [loaded, setLoaded] = React.useState(false); + const [error, setError] = React.useState(); + + const kindName = anchor ? getModelKindName(anchor.kind, k8sModels) : undefined; + + const [workload, workloadLoaded, workloadError] = useK8sWatchResource( + anchor + ? { + isList: false, + kind: anchor.kind, + name: anchor.name, + namespace: anchor.namespace, + } + : null, + ); + + const selector = React.useMemo((): Selector | undefined => { + if (!isDeploymentLike(kindName)) { + return undefined; + } + return workload?.spec?.selector; + }, [kindName, workload?.spec?.selector]); + + const [replicaSets, replicaSetsLoaded, replicaSetsError] = useK8sWatchResource( + anchor && isDeploymentLike(kindName) && selector + ? { + isList: true, + kind: 'ReplicaSet', + namespace: anchor.namespace, + selector, + } + : null, + ); + + const [pods, podsLoaded, podsError] = useK8sWatchResource( + anchor && selector + ? { + isList: true, + kind: 'Pod', + namespace: anchor.namespace, + selector, + } + : null, + ); + + const listsReady = + workloadLoaded && + (!isDeploymentLike(kindName) || (replicaSetsLoaded && podsLoaded)); + + React.useEffect(() => { + if (!anchor?.namespace || !anchor.name || !listsReady) { + return; + } + + let cancelled = false; + + const load = async () => { + setLoaded(false); + setError(undefined); + + const targets = buildEventFetchTargets(anchor, kindName, replicaSets, pods); + const eventResponses = await Promise.all( + targets.map(async (target) => { + const response = (await consoleFetchJSON( + eventsListPath(anchor.namespace!, target.kind, target.name), + 'get', + getRequestInitWithAuthHeader(), + )) as EventListResponse; + return response.items ?? []; + }), + ); + + const timelineEntries: ChangeTimelineEntry[] = []; + eventResponses.flat().forEach((event) => { + const entry = eventToTimelineEntry(event, k8sModels); + if (entry) { + timelineEntries.push(entry); + } + }); + + if (isDeploymentLike(kindName)) { + (replicaSets ?? []).forEach((replicaSet) => { + const entry = replicaSetToTimelineEntry(replicaSet, k8sModels); + if (entry) { + timelineEntries.push(entry); + } + }); + } + + if (!cancelled) { + setEntries(mergeTimelineEntries(timelineEntries, windowMs)); + setLoaded(true); + } + }; + + load().catch((err: unknown) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : String(err)); + setLoaded(true); + } + }); + + return () => { + cancelled = true; + }; + }, [ + anchor, + kindName, + k8sModels, + listsReady, + pods, + replicaSets, + windowMs, + ]); + + React.useEffect(() => { + if (workloadError || replicaSetsError || podsError) { + setError(workloadError || replicaSetsError || podsError); + setLoaded(true); + } + }, [podsError, replicaSetsError, workloadError]); + + return { entries, error, loaded }; +}; diff --git a/src/hooks/useMessageSources.tsx b/src/hooks/useMessageSources.tsx index 848f2d33..d314b406 100644 --- a/src/hooks/useMessageSources.tsx +++ b/src/hooks/useMessageSources.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { useTranslation } from 'react-i18next'; import type { SourcesCardProps } from '@patternfly/chatbot'; -import LivingResourceCard from '../components/LivingResourceCard'; +import LivingResourceSourceBody from '../components/LivingResourceSourceBody'; import { hasEvidenceTour } from '../evidenceTour'; import { useConsoleNavigation } from '../hooks/useConsoleNavigation'; import { extractLivingResources, getLivingResourceOverflow } from '../livingResponse'; @@ -41,6 +41,7 @@ type UseMessageSourcesOptions = { k8sModels: Record; references?: ReferencedDoc[]; responseText?: string; + timelineExpandKey?: string; tools?: Record; }; @@ -57,6 +58,7 @@ export const useMessageSources = ({ k8sModels, references, responseText, + timelineExpandKey, tools, }: UseMessageSourcesOptions): UseMessageSourcesResult => { const { t } = useTranslation('plugin__lightspeed-console-plugin'); @@ -108,10 +110,11 @@ export const useMessageSources = ({ return { body: ( - markUnavailable(key)} resourceRef={ref} + timelineDefaultExpanded={timelineExpandKey === key} /> ), link: path, @@ -125,7 +128,7 @@ export const useMessageSources = ({ title: formatResourceLabel(ref, k8sModels), }; }); - }, [k8sModels, markUnavailable, navigate, t, visibleClusterRefs]); + }, [k8sModels, markUnavailable, navigate, t, timelineExpandKey, visibleClusterRefs]); const docSources = React.useMemo( () => (enabled ? buildDocSources(references) : []), diff --git a/unit-tests/changeTimeline.test.ts b/unit-tests/changeTimeline.test.ts new file mode 100644 index 00000000..aee298a8 --- /dev/null +++ b/unit-tests/changeTimeline.test.ts @@ -0,0 +1,152 @@ +import { describe, it } from 'node:test'; +import { strictEqual } from 'node:assert'; + +import { + eventToTimelineEntry, + formatTimelineRelativeTime, + isChangeTimelineQuery, + isTimelineEligibleAnchor, + mergeTimelineEntries, + resolveTimelineAnchor, + shouldShowChangeTimeline, +} from '../src/changeTimeline'; +import { testK8sModels } from './fixtures/k8sModels'; + +describe('isChangeTimelineQuery', () => { + it('matches explicit change questions', () => { + strictEqual(isChangeTimelineQuery('what changed with payments-api recently?'), true); + strictEqual(isChangeTimelineQuery('can you check why pods are failing?'), false); + }); +}); + +describe('shouldShowChangeTimeline', () => { + const anchor = { kind: 'Deployment', name: 'payments-api', namespace: 'payments' }; + + it('shows for change questions when an anchor exists', () => { + strictEqual(shouldShowChangeTimeline('what changed recently?', undefined, anchor), true); + }); + + it('shows for troubleshooting questions with events evidence', () => { + strictEqual( + shouldShowChangeTimeline( + 'why are aws cloud pods failing?', + { t1: { args: {}, content: '', name: 'events_list', status: 'success' } }, + anchor, + ), + true, + ); + }); +}); + +describe('isTimelineEligibleAnchor', () => { + it('requires a namespaced watchable resource', () => { + strictEqual( + isTimelineEligibleAnchor( + { kind: 'Deployment', name: 'payments-api', namespace: 'payments' }, + testK8sModels, + ), + true, + ); + strictEqual( + isTimelineEligibleAnchor({ kind: 'Node', name: 'worker-1' }, testK8sModels), + false, + ); + }); +}); + +describe('resolveTimelineAnchor', () => { + it('prefers the page context workload when available', () => { + const anchor = resolveTimelineAnchor( + 'Deployment', + 'payments-api', + 'payments', + undefined, + undefined, + testK8sModels, + ); + strictEqual(anchor?.kind, 'Deployment'); + strictEqual(anchor?.name, 'payments-api'); + }); + + it('falls back to deployment refs from tool output', () => { + const anchor = resolveTimelineAnchor( + undefined, + undefined, + undefined, + { + t1: { + args: { namespace: 'payments' }, + content: + 'payments apps/v1 Deployment payments-api 2/2 2 2 3h payments-api registry/payments:latest app=payments', + name: 'resources_list', + status: 'success', + }, + }, + undefined, + testK8sModels, + ); + strictEqual(anchor?.kind, 'Deployment'); + strictEqual(anchor?.name, 'payments-api'); + strictEqual(anchor?.namespace, 'payments'); + }); +}); + +describe('eventToTimelineEntry', () => { + it('maps warning events to timeline rows', () => { + const entry = eventToTimelineEntry( + { + involvedObject: { kind: 'Pod', name: 'payments-api-abc', namespace: 'payments' }, + lastTimestamp: '2026-06-30T12:00:00Z', + message: 'Back-off restarting failed container', + metadata: { uid: 'event-1' }, + reason: 'BackOff', + type: 'Warning', + }, + testK8sModels, + ); + + strictEqual(entry?.severity, 'warning'); + strictEqual(entry?.title, 'BackOff — Pod/payments-api-abc'); + strictEqual(entry?.detail, 'Back-off restarting failed container'); + strictEqual(entry?.consolePath, '/k8s/ns/payments/pods/payments-api-abc'); + }); +}); + +describe('mergeTimelineEntries', () => { + it('sorts newest first and filters outside the window', () => { + const now = new Date('2026-06-30T12:00:00Z'); + const entries = mergeTimelineEntries( + [ + { + id: 'old', + severity: 'normal', + timestamp: new Date('2026-06-30T08:00:00Z'), + title: 'Old', + type: 'event', + }, + { + id: 'new', + severity: 'warning', + timestamp: new Date('2026-06-30T11:30:00Z'), + title: 'New', + type: 'event', + }, + ], + 2 * 60 * 60 * 1000, + now.getTime(), + ); + + strictEqual(entries.length, 1); + strictEqual(entries[0].id, 'new'); + }); +}); + +describe('formatTimelineRelativeTime', () => { + it('formats minutes ago', () => { + const now = Date.parse('2026-06-30T12:00:00Z'); + strictEqual( + formatTimelineRelativeTime(new Date('2026-06-30T11:45:00Z'), now), + '15m ago', + ); + }); +}); From 0d8c2c3b308291867c2b7bf1f1abb2c00c361862 Mon Sep 17 00:00:00 2001 From: Julien Pinsonneau Date: Tue, 30 Jun 2026 17:37:55 +0200 Subject: [PATCH 3/3] handle more statuses --- .../en/plugin__lightspeed-console-plugin.json | 21 ++ src/changeTimeline.ts | 120 +++++++++- src/crStatus.ts | 217 ++++++++++++++++++ src/hooks/useChangeTimeline.ts | 2 +- src/hooks/useChangeTimelineData.ts | 15 +- src/pageContext.ts | 19 ++ src/resourceLiveDetails.ts | 165 ++++++++++++- src/resourceStatus.ts | 195 ++++++++++------ unit-tests/changeTimeline.test.ts | 144 +++++++++++- unit-tests/crStatus.test.ts | 63 +++++ unit-tests/pageContext.test.ts | 33 +++ unit-tests/resourceLiveDetails.test.ts | 52 +++++ unit-tests/resourceStatus.test.ts | 84 +++++++ 13 files changed, 1047 insertions(+), 83 deletions(-) create mode 100644 src/crStatus.ts create mode 100644 unit-tests/crStatus.test.ts diff --git a/locales/en/plugin__lightspeed-console-plugin.json b/locales/en/plugin__lightspeed-console-plugin.json index bb44ae32..aa0ec699 100644 --- a/locales/en/plugin__lightspeed-console-plugin.json +++ b/locales/en/plugin__lightspeed-console-plugin.json @@ -117,6 +117,8 @@ "Living detail roles": "Roles", "Living detail version": "Version", "Living detail phase": "Phase", + "Living detail reason": "Reason", + "Living detail message": "Message", "Living detail type": "Type", "Living detail cluster IP": "Cluster IP", "Living detail ports": "Ports", @@ -124,6 +126,25 @@ "Living detail path": "Path", "Living detail TLS": "TLS", "Living detail data keys": "Data keys", + "Living detail schedule": "Schedule", + "Living detail suspend": "Suspend", + "Living detail last schedule": "Last schedule", + "Living detail active jobs": "Active jobs", + "Living detail completions": "Completions", + "Living detail succeeded": "Succeeded", + "Living detail failed": "Failed", + "Living detail active": "Active", + "Living detail capacity": "Capacity", + "Living detail storage class": "Storage class", + "Living detail volume": "Volume", + "Living detail claim": "Claim", + "Living detail min replicas": "Min replicas", + "Living detail max replicas": "Max replicas", + "Living detail desired replicas": "Desired replicas", + "Living detail current replicas": "Current replicas", + "Living detail hosts": "Hosts", + "Living detail ingress class": "Ingress class", + "Living detail load balancer": "Load balancer", "Loading live resource status": "Loading live resource status", "Loading MCP App...": "Loading MCP App...", "Logs": "Logs", diff --git a/src/changeTimeline.ts b/src/changeTimeline.ts index 4f42e802..e4d4349b 100644 --- a/src/changeTimeline.ts +++ b/src/changeTimeline.ts @@ -6,6 +6,12 @@ import { K8sModelRef, ResourceRef, } from './pageContext'; +import { + GenericCondition, + isPhaseCondition, + isStandardKubeCondition, + variantForPhase, +} from './crStatus'; import { extractWatchableResourceRefs, isWatchableResource } from './livingResponse'; import { normalizeResourceRef } from './resourceRefs'; import { Tool } from './types'; @@ -32,7 +38,7 @@ const TIMELINE_WORKLOAD_PRIORITY = [ 'Pod', ]; -export type TimelineEntryType = 'event' | 'rollout' | 'scale'; +export type TimelineEntryType = 'event' | 'rollout' | 'scale' | 'status'; export type TimelineSeverity = 'warning' | 'error' | 'normal' | 'info'; @@ -82,8 +88,9 @@ export const shouldShowChangeTimeline = ( query: string | undefined, tools: Record | undefined, anchor: TimelineAnchor | null, + models: Record, ): boolean => { - if (!anchor?.name || !anchor.namespace) { + if (!anchor || !isTimelineEligibleAnchor(anchor, models)) { return false; } if (isChangeTimelineQuery(query)) { @@ -95,7 +102,7 @@ export const shouldShowChangeTimeline = ( export const isTimelineEligibleAnchor = ( ref: ResourceRef | undefined, models: Record, -): ref is TimelineAnchor => !!ref && isWatchableResource(ref, models) && !!ref.namespace; +): ref is TimelineAnchor => !!ref && isWatchableResource(ref, models); export const resolveTimelineAnchor = ( pageKind: string | undefined, @@ -105,12 +112,12 @@ export const resolveTimelineAnchor = ( responseText: string | undefined, models: Record, ): TimelineAnchor | null => { - if (pageKind && pageName && pageNamespace) { + if (pageKind && pageName) { const pageRef = normalizeResourceRef( { kind: pageKind, name: pageName, namespace: pageNamespace }, models, ); - if (pageRef && isWatchableResource(pageRef, models)) { + if (pageRef && isTimelineEligibleAnchor(pageRef, models)) { return pageRef; } } @@ -126,8 +133,10 @@ export const resolveTimelineAnchor = ( const fallback = refs.filter((ref) => !prioritized.includes(ref)); const ordered = [...prioritized, ...fallback]; - const anchor = ordered.find((ref) => ref.namespace && ref.name); - return anchor ? { kind: anchor.kind, name: anchor.name, namespace: anchor.namespace } : null; + const anchor = ordered.find((ref) => isTimelineEligibleAnchor(ref, models)); + return anchor + ? { kind: anchor.kind, name: anchor.name, namespace: anchor.namespace } + : null; }; export const eventTimestamp = (event: K8sEventLike): Date | null => { @@ -301,7 +310,10 @@ export const formatTimelineAnchorLabel = ( models: Record, ): string => { const kindName = getModelKindName(anchor.kind, models); - return `${kindName}/${anchor.name} (${anchor.namespace})`; + if (anchor.namespace) { + return `${kindName}/${anchor.name} (${anchor.namespace})`; + } + return `${kindName}/${anchor.name}`; }; export const buildEventFetchTargets = ( @@ -354,7 +366,95 @@ export const buildEventFetchTargets = ( return targets.slice(0, MAX_EVENT_TARGETS); }; -export const eventsListPath = (namespace: string, kind: string, name: string): string => { +export const eventsListPath = (kind: string, name: string, namespace?: string): string => { const fieldSelector = `involvedObject.kind=${kind},involvedObject.name=${name}`; - return `/api/kubernetes/api/v1/namespaces/${namespace}/events?fieldSelector=${encodeURIComponent(fieldSelector)}`; + if (namespace) { + return `/api/kubernetes/api/v1/namespaces/${namespace}/events?fieldSelector=${encodeURIComponent(fieldSelector)}`; + } + return `/api/kubernetes/api/v1/events?fieldSelector=${encodeURIComponent(fieldSelector)}`; +}; + +const conditionSeverity = (condition: GenericCondition): TimelineSeverity => { + if (isStandardKubeCondition(condition)) { + if (condition.type === 'Degraded' || condition.type === 'Failure') { + return condition.status === 'True' ? 'error' : 'normal'; + } + if (condition.status === 'False') { + return 'warning'; + } + return 'normal'; + } + + if (condition.phase) { + const variant = variantForPhase(condition.phase, condition.reason); + if (variant === 'danger') { + return 'error'; + } + if (variant === 'warning') { + return 'warning'; + } + return 'normal'; + } + + return 'info'; +}; + +const formatConditionTitle = (condition: GenericCondition): string => { + if (isStandardKubeCondition(condition)) { + const reason = condition.reason || condition.type || 'Status changed'; + return condition.type ? `${condition.type}: ${reason}` : reason; + } + + if (isPhaseCondition(condition)) { + return condition.reason + ? `${condition.phase}: ${condition.reason}` + : (condition.phase ?? 'Status changed'); + } + + return condition.reason || condition.phase || 'Status changed'; +}; + +export const statusConditionToTimelineEntry = ( + condition: GenericCondition, + index: number, + anchor: TimelineAnchor, + models: Record, +): ChangeTimelineEntry | null => { + const raw = condition.lastTransitionTime; + if (!raw) { + return null; + } + + const timestamp = new Date(raw); + if (Number.isNaN(timestamp.getTime())) { + return null; + } + + const consolePath = buildResourceConsolePath(anchor, models) ?? undefined; + + return { + consolePath, + detail: condition.message?.trim(), + id: `status-${timestamp.getTime()}-${condition.reason ?? condition.type ?? condition.phase ?? index}`, + resourceRef: anchor, + severity: conditionSeverity(condition), + timestamp, + title: formatConditionTitle(condition), + type: 'status', + }; +}; + +export const resourceStatusToTimelineEntries = ( + resource: K8sResourceKind | undefined, + anchor: TimelineAnchor, + models: Record, +): ChangeTimelineEntry[] => { + const conditions = resource?.status?.conditions; + if (!Array.isArray(conditions) || conditions.length === 0) { + return []; + } + + return (conditions as GenericCondition[]) + .map((condition, index) => statusConditionToTimelineEntry(condition, index, anchor, models)) + .filter((entry): entry is ChangeTimelineEntry => entry !== null); }; diff --git a/src/crStatus.ts b/src/crStatus.ts new file mode 100644 index 00000000..b8c16f0f --- /dev/null +++ b/src/crStatus.ts @@ -0,0 +1,217 @@ +import { K8sResourceKind } from '@openshift-console/dynamic-plugin-sdk'; + +import { StatusSummary } from './resourceStatus'; + +export 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'; +}; + +export const formatStatusLabel = (primary: string, secondary?: string): string => { + if (!secondary || secondary === primary) { + return primary; + } + return `${primary} (${secondary})`; +}; + +export const isStandardKubeCondition = (condition: GenericCondition): boolean => + typeof condition.type === 'string' && typeof condition.status === 'string'; + +export 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; +}; + +export const pickLatestCondition = ( + conditions: GenericCondition[], +): GenericCondition | null => { + if (conditions.length === 0) { + return null; + } + + return [...conditions].sort((left, right) => conditionTimestamp(right) - conditionTimestamp(left))[0]; +}; + +export 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 = ['Ready', 'Available', 'Progressing', 'Degraded', 'Failure']; + 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' }; +}; + +export const truncateDetailMessage = (message: string, maxLength: number = 160): string => + message.length <= maxLength ? message : `${message.slice(0, maxLength - 1)}…`; diff --git a/src/hooks/useChangeTimeline.ts b/src/hooks/useChangeTimeline.ts index f2e1ab63..0ef4d6ce 100644 --- a/src/hooks/useChangeTimeline.ts +++ b/src/hooks/useChangeTimeline.ts @@ -33,7 +33,7 @@ export const useChangeTimeline = ( [enabled, k8sModels, pageKind, pageName, pageNamespace, responseText, tools], ); - const showTimeline = enabled && shouldShowChangeTimeline(query, tools, anchor); + const showTimeline = enabled && shouldShowChangeTimeline(query, tools, anchor, k8sModels); return { anchor, showTimeline }; }; diff --git a/src/hooks/useChangeTimelineData.ts b/src/hooks/useChangeTimelineData.ts index 8e3f84d7..04f2e3af 100644 --- a/src/hooks/useChangeTimelineData.ts +++ b/src/hooks/useChangeTimelineData.ts @@ -12,9 +12,11 @@ import { DEFAULT_TIMELINE_WINDOW_MS, eventToTimelineEntry, eventsListPath, + isTimelineEligibleAnchor, K8sEventLike, mergeTimelineEntries, replicaSetToTimelineEntry, + resourceStatusToTimelineEntries, TimelineAnchor, } from '../changeTimeline'; import { getRequestInitWithAuthHeader } from '../hooks/useAuth'; @@ -57,7 +59,7 @@ export const useChangeTimelineData = ( }, [kindName, workload?.spec?.selector]); const [replicaSets, replicaSetsLoaded, replicaSetsError] = useK8sWatchResource( - anchor && isDeploymentLike(kindName) && selector + anchor?.namespace && isDeploymentLike(kindName) && selector ? { isList: true, kind: 'ReplicaSet', @@ -68,7 +70,7 @@ export const useChangeTimelineData = ( ); const [pods, podsLoaded, podsError] = useK8sWatchResource( - anchor && selector + anchor?.namespace && selector ? { isList: true, kind: 'Pod', @@ -83,7 +85,7 @@ export const useChangeTimelineData = ( (!isDeploymentLike(kindName) || (replicaSetsLoaded && podsLoaded)); React.useEffect(() => { - if (!anchor?.namespace || !anchor.name || !listsReady) { + if (!anchor?.name || !isTimelineEligibleAnchor(anchor, k8sModels) || !listsReady) { return; } @@ -97,7 +99,7 @@ export const useChangeTimelineData = ( const eventResponses = await Promise.all( targets.map(async (target) => { const response = (await consoleFetchJSON( - eventsListPath(anchor.namespace!, target.kind, target.name), + eventsListPath(target.kind, target.name, anchor.namespace), 'get', getRequestInitWithAuthHeader(), )) as EventListResponse; @@ -122,6 +124,10 @@ export const useChangeTimelineData = ( }); } + resourceStatusToTimelineEntries(workload, anchor, k8sModels).forEach((entry) => { + timelineEntries.push(entry); + }); + if (!cancelled) { setEntries(mergeTimelineEntries(timelineEntries, windowMs)); setLoaded(true); @@ -146,6 +152,7 @@ export const useChangeTimelineData = ( pods, replicaSets, windowMs, + workload, ]); React.useEffect(() => { diff --git a/src/pageContext.ts b/src/pageContext.ts index de66a2a9..8cf6cc74 100644 --- a/src/pageContext.ts +++ b/src/pageContext.ts @@ -63,6 +63,25 @@ export const resolveKindToModelKey = ( export const getModelKindName = (modelKey: string, models: Record): string => models[modelKey]?.kind ?? modelKey.split('~').pop() ?? modelKey; +export const isClusterScopedRef = ( + ref: ResourceRef, + models: Record, +): boolean => { + const modelKey = resolveKindToModelKey(ref.kind, models); + if (!modelKey) { + return false; + } + return models[modelKey]?.namespaced === false; +}; + +export const isNamespacedRef = (ref: ResourceRef, models: Record): boolean => { + const modelKey = resolveKindToModelKey(ref.kind, models); + if (!modelKey) { + return true; + } + return models[modelKey]?.namespaced !== false; +}; + export const getModelUrlSegment = ( modelKey: string, models: Record, diff --git a/src/resourceLiveDetails.ts b/src/resourceLiveDetails.ts index 3b33f3a2..ff669130 100644 --- a/src/resourceLiveDetails.ts +++ b/src/resourceLiveDetails.ts @@ -1,5 +1,7 @@ import { K8sResourceKind } from '@openshift-console/dynamic-plugin-sdk'; +import { getTopLevelStatus, truncateDetailMessage } from './crStatus'; + export type LiveDetailField = { labelKey: string; value?: string; @@ -46,6 +48,19 @@ const ageField = (resource?: K8sResourceKind): LiveDetailField => ({ value: formatAge(resource?.metadata?.creationTimestamp), }); +const formatOptionalCount = (value: number | undefined): string | undefined => + value === undefined ? undefined : `${value}`; + +const formatSuspendFlag = (suspend: boolean | undefined): string | undefined => { + if (suspend === true) { + return 'true'; + } + if (suspend === false) { + return 'false'; + } + return undefined; +}; + const podFields: LiveDetailExtractor = (resource) => { const containerStatuses = [ ...((resource?.status?.containerStatuses as ContainerStatus[] | undefined) ?? []), @@ -156,7 +171,148 @@ const keyValueDataFields: LiveDetailExtractor = (resource) => { ]; }; -const defaultFields: LiveDetailExtractor = (resource) => [ageField(resource)]; +const jobFields: LiveDetailExtractor = (resource) => { + const completions = resource?.spec?.completions; + const succeeded = resource?.status?.succeeded; + const failed = resource?.status?.failed; + const active = resource?.status?.active; + + return [ + ageField(resource), + { + labelKey: 'Living detail completions', + value: + succeeded !== undefined && completions !== undefined + ? `${succeeded}/${completions}` + : undefined, + }, + { + labelKey: 'Living detail succeeded', + value: formatOptionalCount(succeeded), + }, + { + labelKey: 'Living detail failed', + value: formatOptionalCount(failed), + }, + { + labelKey: 'Living detail active', + value: formatOptionalCount(active), + }, + ]; +}; + +const cronJobFields: LiveDetailExtractor = (resource) => { + const active = (resource?.status?.active as { name?: string }[] | undefined)?.length; + + return [ + ageField(resource), + { labelKey: 'Living detail schedule', value: resource?.spec?.schedule }, + { + labelKey: 'Living detail suspend', + value: formatSuspendFlag(resource?.spec?.suspend), + }, + { + labelKey: 'Living detail last schedule', + value: resource?.status?.lastScheduleTime, + }, + { + labelKey: 'Living detail active jobs', + value: formatOptionalCount(active), + }, + ]; +}; + +const pvcFields: LiveDetailExtractor = (resource) => { + const capacity = resource?.status?.capacity?.storage as string | undefined; + + return [ + ageField(resource), + { labelKey: 'Living detail phase', value: resource?.status?.phase }, + { labelKey: 'Living detail capacity', value: capacity }, + { + labelKey: 'Living detail storage class', + value: resource?.spec?.storageClassName, + }, + { labelKey: 'Living detail volume', value: resource?.spec?.volumeName }, + ]; +}; + +const pvFields: LiveDetailExtractor = (resource) => { + const capacity = resource?.spec?.capacity?.storage as string | undefined; + const claim = resource?.spec?.claimRef as { namespace?: string; name?: string } | undefined; + const claimRef = + claim?.name && claim?.namespace ? `${claim.namespace}/${claim.name}` : claim?.name; + + return [ + ageField(resource), + { labelKey: 'Living detail phase', value: resource?.status?.phase }, + { labelKey: 'Living detail capacity', value: capacity }, + { labelKey: 'Living detail storage class', value: resource?.spec?.storageClassName }, + { labelKey: 'Living detail claim', value: claimRef }, + ]; +}; + +const hpaFields: LiveDetailExtractor = (resource) => [ + ageField(resource), + { + labelKey: 'Living detail current replicas', + value: formatOptionalCount(resource?.status?.currentReplicas), + }, + { + labelKey: 'Living detail desired replicas', + value: formatOptionalCount(resource?.status?.desiredReplicas), + }, + { + labelKey: 'Living detail min replicas', + value: formatOptionalCount(resource?.spec?.minReplicas), + }, + { + labelKey: 'Living detail max replicas', + value: formatOptionalCount(resource?.spec?.maxReplicas), + }, +]; + +const ingressFields: LiveDetailExtractor = (resource) => { + const hosts = (resource?.spec?.rules as { host?: string }[] | undefined) + ?.map((rule) => rule.host) + .filter(Boolean) + .join(', '); + const loadBalancer = ( + resource?.status?.loadBalancer?.ingress as { hostname?: string; ip?: string }[] | undefined + ) + ?.map((entry) => entry.hostname || entry.ip) + .filter(Boolean) + .join(', '); + + return [ + ageField(resource), + { labelKey: 'Living detail hosts', value: hosts }, + { labelKey: 'Living detail ingress class', value: resource?.spec?.ingressClassName }, + { labelKey: 'Living detail load balancer', value: loadBalancer }, + ]; +}; + +const genericStatusFields: LiveDetailExtractor = (resource) => { + const topLevel = getTopLevelStatus(resource?.status); + const fields: LiveDetailField[] = [ageField(resource)]; + + if (topLevel.phase) { + fields.push({ labelKey: 'Living detail phase', value: topLevel.phase }); + } + if (topLevel.reason) { + fields.push({ labelKey: 'Living detail reason', value: topLevel.reason }); + } + if (topLevel.message) { + fields.push({ + labelKey: 'Living detail message', + value: truncateDetailMessage(topLevel.message), + }); + } + + return fields; +}; + +const defaultFields: LiveDetailExtractor = genericStatusFields; const LIVE_DETAIL_EXTRACTORS: Record = { Pod: podFields, @@ -164,7 +320,14 @@ const LIVE_DETAIL_EXTRACTORS: Record = { StatefulSet: workloadFields, DaemonSet: workloadFields, ReplicaSet: workloadFields, + ReplicationController: workloadFields, DeploymentConfig: workloadFields, + Job: jobFields, + CronJob: cronJobFields, + PersistentVolumeClaim: pvcFields, + PersistentVolume: pvFields, + HorizontalPodAutoscaler: hpaFields, + Ingress: ingressFields, Node: nodeFields, Namespace: namespaceFields, Service: serviceFields, diff --git a/src/resourceStatus.ts b/src/resourceStatus.ts index 6228c945..18afa0a6 100644 --- a/src/resourceStatus.ts +++ b/src/resourceStatus.ts @@ -1,5 +1,6 @@ import { K8sResourceKind } from '@openshift-console/dynamic-plugin-sdk'; +import { getGenericResourceStatus, variantForPhase } from './crStatus'; import { getModelKindName, K8sModelRef } from './pageContext'; export type StatusSummary = { @@ -14,44 +15,106 @@ type ResourceCondition = { type?: string; }; -const variantForCondition = (status?: string): StatusSummary['variant'] => { - if (status === 'True') { - return 'success'; +const statusFromPhase = (phase: string): StatusSummary => ({ + label: phase, + variant: variantForPhase(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 (ready === total && total > 0) { + return { label, variant: 'success' }; } - if (status === 'False') { - return 'danger'; + if (ready > 0) { + return { label, variant: 'warning' }; } - return 'warning'; + return { label, variant: 'danger' }; }; -const getConditionStatus = (conditions: ResourceCondition[]): StatusSummary | null => { - const priority = ['Ready', 'Available', 'Progressing', 'Degraded', 'Failure']; - for (const type of priority) { - const condition = conditions.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', - }; - } +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, - variant: variantForCondition(condition.status), + label: `${current}/${desired} replicas`, + variant: current === desired ? 'success' : 'warning', }; } - const first = conditions[0]; - if (!first) { - return null; - } - return { - label: first.reason || first.type || 'Unknown', - variant: variantForCondition(first.status), - }; + return getGenericResourceStatus(resource); }; export const getResourceStatusSummary = ( @@ -76,22 +139,44 @@ export const getResourceStatusSummary = ( case 'Deployment': case 'StatefulSet': case 'DaemonSet': - case 'ReplicaSet': { - 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 (ready === total && total > 0) { - return { label, variant: 'success' }; + 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); } - if (ready > 0) { - return { label, variant: 'warning' }; + 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' }; + } + if (type === 'ExternalName') { + return { label: 'ExternalName', variant: 'info' }; } - return { label, variant: 'danger' }; + return { label: type, variant: 'success' }; } + 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', @@ -102,28 +187,8 @@ export const getResourceStatusSummary = ( variant: isReady ? 'success' : 'danger', }; } - default: { - const conditions = resource.status?.conditions; - if (Array.isArray(conditions) && conditions.length > 0) { - const fromConditions = getConditionStatus(conditions as ResourceCondition[]); - if (fromConditions) { - return fromConditions; - } - } - - const phase = resource.status?.phase; - if (typeof phase === 'string') { - if (phase === 'Running' || phase === 'Ready' || phase === 'Available') { - return { label: phase, variant: 'success' }; - } - if (phase === 'Pending') { - return { label: phase, variant: 'warning' }; - } - return { label: phase, variant: 'danger' }; - } - - return { label: 'Live', variant: 'info' }; - } + default: + return getGenericResourceStatus(resource); } }; diff --git a/unit-tests/changeTimeline.test.ts b/unit-tests/changeTimeline.test.ts index aee298a8..4f1ad6aa 100644 --- a/unit-tests/changeTimeline.test.ts +++ b/unit-tests/changeTimeline.test.ts @@ -3,11 +3,14 @@ import { strictEqual } from 'node:assert'; import { eventToTimelineEntry, + eventsListPath, + formatTimelineAnchorLabel, formatTimelineRelativeTime, isChangeTimelineQuery, isTimelineEligibleAnchor, mergeTimelineEntries, resolveTimelineAnchor, + resourceStatusToTimelineEntries, shouldShowChangeTimeline, } from '../src/changeTimeline'; import { testK8sModels } from './fixtures/k8sModels'; @@ -21,9 +24,13 @@ describe('isChangeTimelineQuery', () => { describe('shouldShowChangeTimeline', () => { const anchor = { kind: 'Deployment', name: 'payments-api', namespace: 'payments' }; + const clusterAnchor = { kind: 'Node', name: 'worker-1' }; it('shows for change questions when an anchor exists', () => { - strictEqual(shouldShowChangeTimeline('what changed recently?', undefined, anchor), true); + strictEqual( + shouldShowChangeTimeline('what changed recently?', undefined, anchor, testK8sModels), + true, + ); }); it('shows for troubleshooting questions with events evidence', () => { @@ -32,14 +39,22 @@ describe('shouldShowChangeTimeline', () => { 'why are aws cloud pods failing?', { t1: { args: {}, content: '', name: 'events_list', status: 'success' } }, anchor, + testK8sModels, ), true, ); }); + + it('supports cluster-scoped anchors', () => { + strictEqual( + shouldShowChangeTimeline('what changed on this node?', undefined, clusterAnchor, testK8sModels), + true, + ); + }); }); describe('isTimelineEligibleAnchor', () => { - it('requires a namespaced watchable resource', () => { + it('accepts namespaced watchable resources', () => { strictEqual( isTimelineEligibleAnchor( { kind: 'Deployment', name: 'payments-api', namespace: 'payments' }, @@ -47,8 +62,25 @@ describe('isTimelineEligibleAnchor', () => { ), true, ); + }); + + it('accepts cluster-scoped watchable resources', () => { strictEqual( isTimelineEligibleAnchor({ kind: 'Node', name: 'worker-1' }, testK8sModels), + true, + ); + strictEqual( + isTimelineEligibleAnchor( + { kind: 'flows.netobserv.io~v1beta2~FlowCollector', name: 'cluster' }, + testK8sModels, + ), + true, + ); + }); + + it('rejects namespaced resources without namespace', () => { + strictEqual( + isTimelineEligibleAnchor({ kind: 'Pod', name: 'payments-api-abc' }, testK8sModels), false, ); }); @@ -89,6 +121,40 @@ describe('resolveTimelineAnchor', () => { strictEqual(anchor?.name, 'payments-api'); strictEqual(anchor?.namespace, 'payments'); }); + + it('resolves cluster-scoped refs from tool output', () => { + const anchor = resolveTimelineAnchor( + undefined, + undefined, + undefined, + { + t1: { + args: { kind: 'flows.netobserv.io~v1beta2~FlowCollector', name: 'cluster' }, + content: '', + name: 'resources_get', + status: 'success', + }, + }, + 'FlowCollector cluster is degraded.', + testK8sModels, + ); + strictEqual(anchor?.kind, 'flows.netobserv.io~v1beta2~FlowCollector'); + strictEqual(anchor?.name, 'cluster'); + strictEqual(anchor?.namespace, undefined); + }); + + it('uses cluster page context without namespace', () => { + const anchor = resolveTimelineAnchor( + 'Node', + 'worker-1', + undefined, + undefined, + undefined, + testK8sModels, + ); + strictEqual(anchor?.kind, 'Node'); + strictEqual(anchor?.name, 'worker-1'); + }); }); describe('eventToTimelineEntry', () => { @@ -141,6 +207,80 @@ describe('mergeTimelineEntries', () => { }); }); +describe('resourceStatusToTimelineEntries', () => { + it('maps OLM-style status conditions into timeline rows', () => { + const anchor = { + kind: 'operators.coreos.com~v1alpha1~ClusterServiceVersion', + name: 'lightspeed-operator.v1.1.1', + namespace: 'openshift-lightspeed', + }; + const entries = resourceStatusToTimelineEntries( + { + status: { + conditions: [ + { + lastTransitionTime: '2026-06-30T09:06:44Z', + message: 'deployment not available', + phase: 'Failed', + reason: 'ComponentUnhealthy', + }, + { + lastTransitionTime: '2026-06-30T09:11:07Z', + message: 'install strategy completed with no errors', + phase: 'Succeeded', + reason: 'InstallSucceeded', + }, + ], + }, + }, + anchor, + testK8sModels, + ); + + strictEqual(entries.length, 2); + const titles = entries.map((entry) => entry.title).sort(); + strictEqual(titles[0], 'Failed: ComponentUnhealthy'); + strictEqual(titles[1], 'Succeeded: InstallSucceeded'); + strictEqual(entries.find((entry) => entry.title === 'Failed: ComponentUnhealthy')?.severity, 'error'); + strictEqual(entries.every((entry) => entry.type === 'status'), true); + }); +}); + +describe('eventsListPath', () => { + it('builds namespaced event list paths', () => { + strictEqual( + eventsListPath('Pod', 'payments-api-abc', 'payments'), + '/api/kubernetes/api/v1/namespaces/payments/events?fieldSelector=involvedObject.kind%3DPod%2CinvolvedObject.name%3Dpayments-api-abc', + ); + }); + + it('builds cluster-wide event list paths', () => { + strictEqual( + eventsListPath('Node', 'worker-1'), + '/api/kubernetes/api/v1/events?fieldSelector=involvedObject.kind%3DNode%2CinvolvedObject.name%3Dworker-1', + ); + }); +}); + +describe('formatTimelineAnchorLabel', () => { + it('omits namespace for cluster-scoped anchors', () => { + strictEqual( + formatTimelineAnchorLabel({ kind: 'Node', name: 'worker-1' }, testK8sModels), + 'Node/worker-1', + ); + }); + + it('includes namespace for namespaced anchors', () => { + strictEqual( + formatTimelineAnchorLabel( + { kind: 'Deployment', name: 'payments-api', namespace: 'payments' }, + testK8sModels, + ), + 'Deployment/payments-api (payments)', + ); + }); +}); + describe('formatTimelineRelativeTime', () => { it('formats minutes ago', () => { const now = Date.parse('2026-06-30T12:00:00Z'); diff --git a/unit-tests/crStatus.test.ts b/unit-tests/crStatus.test.ts new file mode 100644 index 00000000..424f24ca --- /dev/null +++ b/unit-tests/crStatus.test.ts @@ -0,0 +1,63 @@ +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'); + }); +}); diff --git a/unit-tests/pageContext.test.ts b/unit-tests/pageContext.test.ts index 745de5ec..8774cf97 100644 --- a/unit-tests/pageContext.test.ts +++ b/unit-tests/pageContext.test.ts @@ -4,6 +4,8 @@ import { strictEqual } from 'node:assert'; import { buildPageContext, buildResourceConsolePath, + isClusterScopedRef, + isNamespacedRef, resolveKindToModelKey, resolveModelKey, } from '../src/pageContext'; @@ -115,6 +117,37 @@ describe('resolveKindToModelKey', () => { }); }); +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( diff --git a/unit-tests/resourceLiveDetails.test.ts b/unit-tests/resourceLiveDetails.test.ts index 6cbb7dab..148f9279 100644 --- a/unit-tests/resourceLiveDetails.test.ts +++ b/unit-tests/resourceLiveDetails.test.ts @@ -84,4 +84,56 @@ describe('getResourceLiveDetails', () => { strictEqual(fields[0].labelKey, 'Living detail age'); strictEqual(fields[0].value, '2h'); }); + + it('extracts generic status fields for custom resources', () => { + const fields = getResourceLiveDetails('ClusterServiceVersion', { + metadata: { creationTimestamp: new Date().toISOString() }, + status: { + phase: 'Installing', + reason: 'InstallWaiting', + message: 'waiting for install components to report healthy', + }, + }); + + strictEqual(fieldValue(fields, 'Living detail phase'), 'Installing'); + strictEqual(fieldValue(fields, 'Living detail reason'), 'InstallWaiting'); + strictEqual(fieldValue(fields, 'Living detail message'), 'waiting for install components to report healthy'); + }); + + it('extracts job completion fields', () => { + const fields = getResourceLiveDetails('Job', { + metadata: { creationTimestamp: new Date().toISOString() }, + spec: { completions: 1 }, + status: { succeeded: 1, failed: 0, active: 0 }, + }); + + strictEqual(fieldValue(fields, 'Living detail completions'), '1/1'); + strictEqual(fieldValue(fields, 'Living detail succeeded'), '1'); + }); + + it('extracts PVC storage fields', () => { + const fields = getResourceLiveDetails('PersistentVolumeClaim', { + metadata: { creationTimestamp: new Date().toISOString() }, + spec: { storageClassName: 'gp3', volumeName: 'pvc-123' }, + status: { phase: 'Bound', capacity: { storage: '10Gi' } }, + }); + + strictEqual(fieldValue(fields, 'Living detail phase'), 'Bound'); + strictEqual(fieldValue(fields, 'Living detail capacity'), '10Gi'); + strictEqual(fieldValue(fields, 'Living detail storage class'), 'gp3'); + strictEqual(fieldValue(fields, 'Living detail volume'), 'pvc-123'); + }); + + it('extracts HPA replica fields', () => { + const fields = getResourceLiveDetails('HorizontalPodAutoscaler', { + metadata: { creationTimestamp: new Date().toISOString() }, + spec: { minReplicas: 1, maxReplicas: 5 }, + status: { currentReplicas: 2, desiredReplicas: 3 }, + }); + + strictEqual(fieldValue(fields, 'Living detail current replicas'), '2'); + strictEqual(fieldValue(fields, 'Living detail desired replicas'), '3'); + strictEqual(fieldValue(fields, 'Living detail min replicas'), '1'); + strictEqual(fieldValue(fields, 'Living detail max replicas'), '5'); + }); }); diff --git a/unit-tests/resourceStatus.test.ts b/unit-tests/resourceStatus.test.ts index 2120ccc2..6c6805c2 100644 --- a/unit-tests/resourceStatus.test.ts +++ b/unit-tests/resourceStatus.test.ts @@ -23,4 +23,88 @@ describe('getResourceStatusSummary', () => { 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 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('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'); + }); });