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..aa0ec699 100644 --- a/locales/en/plugin__lightspeed-console-plugin.json +++ b/locales/en/plugin__lightspeed-console-plugin.json @@ -16,6 +16,15 @@ "Cancel": "Cancel", "Cancelled": "Cancelled", "Changes you made may not be saved.": "Changes you made may not be saved.", + "Change timeline": "Change timeline", + "Failed to load change timeline": "Failed to load change timeline", + "Loading change timeline": "Loading change timeline", + "No recent changes found": "No recent changes found", + "Timeline for {{target}} (last 2 hours)": "Timeline for {{target}} (last 2 hours)", + "Timeline severity error": "Error", + "Timeline severity info": "Info", + "Timeline severity normal": "Normal", + "Timeline severity warning": "Warning", "Clear chat": "Clear chat", "Collapse": "Collapse", "Compressed in {{seconds}} seconds": "Compressed in {{seconds}} seconds", @@ -40,6 +49,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 +60,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.", @@ -69,7 +80,8 @@ "For questions or feedback about OpenShift Lightspeed,": "For questions or feedback about OpenShift Lightspeed,", "Full YAML file": "Full YAML file", "Good response": "Good response", - "History compressed": "History compressed", + "View timeline": "View timeline", + "Hide timeline": "Hide timeline", "History truncated": "History truncated", "If this error persists, please contact an administrator. Error details: {{e}}": "If this error persists, please contact an administrator. Error details: {{e}}", "Import to console": "Import to console", @@ -78,6 +90,62 @@ "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 reason": "Reason", + "Living detail message": "Message", + "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", + "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", "MCP App Error": "MCP App Error", @@ -87,6 +155,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 +167,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 +177,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 +186,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 +204,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/changeTimeline.ts b/src/changeTimeline.ts new file mode 100644 index 00000000..e4d4349b --- /dev/null +++ b/src/changeTimeline.ts @@ -0,0 +1,460 @@ +import { K8sResourceKind } from '@openshift-console/dynamic-plugin-sdk'; + +import { + buildResourceConsolePath, + getModelKindName, + K8sModelRef, + ResourceRef, +} from './pageContext'; +import { + GenericCondition, + isPhaseCondition, + isStandardKubeCondition, + variantForPhase, +} from './crStatus'; +import { extractWatchableResourceRefs, isWatchableResource } from './livingResponse'; +import { normalizeResourceRef } from './resourceRefs'; +import { Tool } from './types'; + +export const DEFAULT_TIMELINE_WINDOW_MS = 2 * 60 * 60 * 1000; + +const MAX_EVENT_TARGETS = 12; + +const CHANGE_QUERY_PATTERN = + /\b(what changed|what's changed|what has changed|recent changes|change timeline|history of changes|when did .+ change|what happened)\b/i; + +const TROUBLESHOOTING_QUERY_PATTERN = + /\b(fail(ing|ed|ure)?|crash(ing|ed)?|unhealthy|error|why\b|what went wrong|what happened)\b/i; + +const TIMELINE_WORKLOAD_PRIORITY = [ + 'Deployment', + 'DeploymentConfig', + 'StatefulSet', + 'DaemonSet', + 'ReplicaSet', + 'ReplicationController', + 'Job', + 'CronJob', + 'Pod', +]; + +export type TimelineEntryType = 'event' | 'rollout' | 'scale' | 'status'; + +export type TimelineSeverity = 'warning' | 'error' | 'normal' | 'info'; + +export type TimelineAnchor = ResourceRef; + +export type ChangeTimelineEntry = { + consolePath?: string; + detail?: string; + id: string; + resourceRef?: ResourceRef; + severity: TimelineSeverity; + timestamp: Date; + title: string; + type: TimelineEntryType; +}; + +export type K8sEventLike = { + eventTime?: string; + firstTimestamp?: string; + involvedObject?: { + kind?: string; + name?: string; + namespace?: string; + }; + lastTimestamp?: string; + message?: string; + metadata?: { + creationTimestamp?: string; + uid?: string; + }; + reason?: string; + type?: string; +}; + +export const isChangeTimelineQuery = (query?: string): boolean => + !!query && CHANGE_QUERY_PATTERN.test(query); + +export const isTroubleshootingTimelineQuery = (query?: string): boolean => + !!query && TROUBLESHOOTING_QUERY_PATTERN.test(query); + +export const hasEventsEvidence = (tools?: Record): boolean => + Object.values(tools ?? {}).some( + (tool) => tool.name === 'events_list' && tool.status !== 'error' && !tool.isDenied, + ); + +export const shouldShowChangeTimeline = ( + query: string | undefined, + tools: Record | undefined, + anchor: TimelineAnchor | null, + models: Record, +): boolean => { + if (!anchor || !isTimelineEligibleAnchor(anchor, models)) { + return false; + } + if (isChangeTimelineQuery(query)) { + return true; + } + return isTroubleshootingTimelineQuery(query) && hasEventsEvidence(tools); +}; + +export const isTimelineEligibleAnchor = ( + ref: ResourceRef | undefined, + models: Record, +): ref is TimelineAnchor => !!ref && isWatchableResource(ref, models); + +export const resolveTimelineAnchor = ( + pageKind: string | undefined, + pageName: string | undefined, + pageNamespace: string | undefined, + tools: Record | undefined, + responseText: string | undefined, + models: Record, +): TimelineAnchor | null => { + if (pageKind && pageName) { + const pageRef = normalizeResourceRef( + { kind: pageKind, name: pageName, namespace: pageNamespace }, + models, + ); + if (pageRef && isTimelineEligibleAnchor(pageRef, models)) { + return pageRef; + } + } + + const refs = extractWatchableResourceRefs(tools, responseText, models); + const prioritized = TIMELINE_WORKLOAD_PRIORITY.flatMap((kindName) => { + const modelKey = Object.keys(models).find((key) => models[key].kind === kindName); + if (!modelKey) { + return []; + } + return refs.filter((ref) => ref.kind === modelKey); + }); + const fallback = refs.filter((ref) => !prioritized.includes(ref)); + const ordered = [...prioritized, ...fallback]; + + 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 => { + const raw = + event.lastTimestamp ?? + event.firstTimestamp ?? + event.eventTime ?? + event.metadata?.creationTimestamp; + if (!raw) { + return null; + } + const parsed = new Date(raw); + return Number.isNaN(parsed.getTime()) ? null : parsed; +}; + +export const eventSeverity = (event: K8sEventLike): TimelineSeverity => { + if (event.type === 'Warning') { + return 'warning'; + } + if (event.type === 'Error') { + return 'error'; + } + if (event.reason === 'ScalingReplicaSet') { + return 'info'; + } + return 'normal'; +}; + +export const eventEntryType = (event: K8sEventLike): TimelineEntryType => { + if (event.reason === 'ScalingReplicaSet') { + return 'scale'; + } + return 'event'; +}; + +export const formatEventTitle = (event: K8sEventLike): string => { + const reason = event.reason?.trim(); + const involved = event.involvedObject; + const objectLabel = + involved?.kind && involved?.name ? `${involved.kind}/${involved.name}` : undefined; + if (reason && objectLabel) { + return `${reason} — ${objectLabel}`; + } + return reason || objectLabel || 'Cluster event'; +}; + +export const eventToTimelineEntry = ( + event: K8sEventLike, + models: Record, +): ChangeTimelineEntry | null => { + const timestamp = eventTimestamp(event); + if (!timestamp) { + return null; + } + + const involved = event.involvedObject; + const resourceRef = + involved?.kind && involved?.name + ? (normalizeResourceRef( + { + kind: involved.kind, + name: involved.name, + namespace: involved.namespace, + }, + models, + ) ?? undefined) + : undefined; + + const consolePath = resourceRef + ? (buildResourceConsolePath(resourceRef, models) ?? undefined) + : undefined; + + return { + consolePath, + detail: event.message?.trim(), + id: `event-${event.metadata?.uid ?? `${timestamp.getTime()}-${event.reason}-${involved?.name}`}`, + resourceRef, + severity: eventSeverity(event), + timestamp, + title: formatEventTitle(event), + type: eventEntryType(event), + }; +}; + +const containerImageSummary = (resource?: K8sResourceKind): string | undefined => { + const containers = resource?.spec?.template?.spec?.containers as { image?: string }[] | undefined; + const image = containers?.[0]?.image; + if (!image) { + return undefined; + } + const tag = image.includes('@') ? image.split('@')[0] : image.split(':').pop(); + return tag ? `image ${tag}` : image; +}; + +export const replicaSetToTimelineEntry = ( + replicaSet: K8sResourceKind, + models: Record, +): ChangeTimelineEntry | null => { + const name = replicaSet.metadata?.name; + const namespace = replicaSet.metadata?.namespace; + const created = replicaSet.metadata?.creationTimestamp; + if (!name || !namespace || !created) { + return null; + } + + const timestamp = new Date(created); + if (Number.isNaN(timestamp.getTime())) { + return null; + } + + const resourceRef = normalizeResourceRef({ kind: 'ReplicaSet', name, namespace }, models); + if (!resourceRef) { + return null; + } + + const imageSummary = containerImageSummary(replicaSet); + return { + consolePath: buildResourceConsolePath(resourceRef, models) ?? undefined, + detail: imageSummary, + id: `rollout-${replicaSet.metadata?.uid ?? name}`, + resourceRef, + severity: 'info', + timestamp, + title: `New ReplicaSet ${name}`, + type: 'rollout', + }; +}; + +export const mergeTimelineEntries = ( + entries: ChangeTimelineEntry[], + windowMs: number = DEFAULT_TIMELINE_WINDOW_MS, + nowMs: number = Date.now(), +): ChangeTimelineEntry[] => { + const cutoff = nowMs - windowMs; + const seen = new Set(); + const merged: ChangeTimelineEntry[] = []; + + for (const entry of entries) { + if (entry.timestamp.getTime() < cutoff) { + continue; + } + if (seen.has(entry.id)) { + continue; + } + seen.add(entry.id); + merged.push(entry); + } + + return merged.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); +}; + +export const formatTimelineRelativeTime = (timestamp: Date, nowMs: number = Date.now()): string => { + const elapsedSeconds = Math.max(0, Math.floor((nowMs - timestamp.getTime()) / 1000)); + if (elapsedSeconds < 60) { + return `${elapsedSeconds}s ago`; + } + const minutes = Math.floor(elapsedSeconds / 60); + if (minutes < 60) { + return `${minutes}m ago`; + } + const hours = Math.floor(minutes / 60); + if (hours < 48) { + return `${hours}h ago`; + } + const days = Math.floor(hours / 24); + return `${days}d ago`; +}; + +export const formatTimelineAnchorLabel = ( + anchor: TimelineAnchor, + models: Record, +): string => { + const kindName = getModelKindName(anchor.kind, models); + if (anchor.namespace) { + return `${kindName}/${anchor.name} (${anchor.namespace})`; + } + return `${kindName}/${anchor.name}`; +}; + +export const buildEventFetchTargets = ( + anchor: TimelineAnchor, + kindName: string | undefined, + replicaSets: K8sResourceKind[] | undefined, + pods: K8sResourceKind[] | undefined, +): { kind: string; name: string }[] => { + const targets: { kind: string; name: string }[] = []; + const seen = new Set(); + + const add = (kind: string, name?: string) => { + if (!name) { + return; + } + const key = `${kind}/${name}`; + if (seen.has(key)) { + return; + } + seen.add(key); + targets.push({ kind, name }); + }; + + if (kindName) { + add(kindName, anchor.name); + } + + if (kindName === 'Deployment' || kindName === 'DeploymentConfig') { + const sortedReplicaSets = [...(replicaSets ?? [])].sort((left, right) => { + const leftTime = new Date(left.metadata?.creationTimestamp ?? 0).getTime(); + const rightTime = new Date(right.metadata?.creationTimestamp ?? 0).getTime(); + return rightTime - leftTime; + }); + for (const replicaSet of sortedReplicaSets.slice(0, 5)) { + add('ReplicaSet', replicaSet.metadata?.name); + } + + const sortedPods = [...(pods ?? [])].sort((left, right) => { + const leftTime = new Date(left.metadata?.creationTimestamp ?? 0).getTime(); + const rightTime = new Date(right.metadata?.creationTimestamp ?? 0).getTime(); + return rightTime - leftTime; + }); + for (const pod of sortedPods.slice(0, 5)) { + add('Pod', pod.metadata?.name); + } + } else if (kindName === 'Pod') { + add('Pod', anchor.name); + } + + return targets.slice(0, MAX_EVENT_TARGETS); +}; + +export const eventsListPath = (kind: string, name: string, namespace?: string): string => { + const fieldSelector = `involvedObject.kind=${kind},involvedObject.name=${name}`; + 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/components/ChangeTimelinePanel.tsx b/src/components/ChangeTimelinePanel.tsx new file mode 100644 index 00000000..e9fb368b --- /dev/null +++ b/src/components/ChangeTimelinePanel.tsx @@ -0,0 +1,131 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Label, Spinner } from '@patternfly/react-core'; + +import { + ChangeTimelineEntry, + formatTimelineAnchorLabel, + formatTimelineRelativeTime, + TimelineAnchor, + TimelineSeverity, +} from '../changeTimeline'; +import { useConsoleNavigation } from '../hooks/useConsoleNavigation'; +import { useChangeTimelineData } from '../hooks/useChangeTimelineData'; +import { K8sModelRef } from '../pageContext'; + +import './change-timeline.css'; + +const severityLabel = (severity: TimelineSeverity, t: (key: string) => string): string => { + switch (severity) { + case 'warning': + return t('Timeline severity warning'); + case 'error': + return t('Timeline severity error'); + case 'info': + return t('Timeline severity info'); + default: + return t('Timeline severity normal'); + } +}; + +const severityColor = (severity: TimelineSeverity): 'blue' | 'red' | 'orange' | 'grey' => { + switch (severity) { + case 'warning': + return 'orange'; + case 'error': + return 'red'; + case 'info': + return 'blue'; + default: + return 'grey'; + } +}; + +type TimelineEntryRowProps = { + entry: ChangeTimelineEntry; + onNavigate: (path: string) => void; +}; + +const TimelineEntryRow: React.FC = ({ entry, onNavigate }) => { + const { t } = useTranslation('plugin__lightspeed-console-plugin'); + + return ( +
  • + + {formatTimelineRelativeTime(entry.timestamp)} + + +
    + {entry.consolePath ? ( + + ) : ( + {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 new file mode 100644 index 00000000..9f0be596 --- /dev/null +++ b/src/components/EvidenceTourPanel.tsx @@ -0,0 +1,202 @@ +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 EvidenceTourStepTitle from './EvidenceTourStepTitle'; +import LivingResourceCard from './LivingResourceCard'; + +import './evidence-tour.css'; +import './change-timeline.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..e043c440 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,15 @@ 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 { useChangeTimeline } from '../hooks/useChangeTimeline'; +import { useMessageSources } from '../hooks/useMessageSources'; +import { compactResponseForLivingResources } from '../livingResponse'; +import { resourceRefKey } from '../resourceRefs'; import { attachmentsClear, chatHistoryClear, @@ -55,14 +60,18 @@ 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 ChangeTimelinePanel from './ChangeTimelinePanel'; +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 +93,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 +124,363 @@ 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 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 = + 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 && ( + + )} + {changeTimeline.showTimeline && + changeTimeline.anchor && + !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 +528,7 @@ const PrivacyAlert: React.FC = () => { type GeneralPageProps = { ariaLabel: string; className: string; + isExpanded?: boolean; onClose: () => void; onCollapse?: () => void; onExpand?: () => void; @@ -480,6 +537,7 @@ type GeneralPageProps = { const GeneralPage: React.FC = ({ ariaLabel, className, + isExpanded = false, onClose, onCollapse, onExpand, @@ -496,10 +554,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 +620,9 @@ const GeneralPage: React.FC = ({ return ( @@ -591,7 +656,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..2be63876 --- /dev/null +++ b/src/components/LivingResourceCard.tsx @@ -0,0 +1,145 @@ +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 { isTimelineEligibleAnchor } from '../changeTimeline'; +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 ChangeTimelinePanel from './ChangeTimelinePanel'; +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; + showTimeline?: boolean; +}; + +const LivingResourceCard: React.FC = ({ + k8sModels, + onUnavailable, + resourceRef, + showTimeline = false, +}) => { + 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 && ( + + + + )} + {showTimeline && isTimelineEligibleAnchor(resourceRef, k8sModels) && ( + + + + )} + + )} +
    + ); +}; + +export default LivingResourceCard; 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/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 ? ( - - ) : ( - - )} +