diff --git a/backend/companion/TokenManager.js b/backend/companion/TokenManager.js new file mode 100644 index 0000000000..565f895467 --- /dev/null +++ b/backend/companion/TokenManager.js @@ -0,0 +1,50 @@ +import { getKcpToken } from './getKcpToken'; + +export class TokenManager { + constructor() { + this.currentToken = null; + this.tokenExpirationTime = null; + // Add buffer time (e.g., 5 minutes) before actual expiration to prevent edge cases + this.expirationBuffer = 5 * 60 * 1000; + } + + async getToken() { + // Check if token exists and is not near expiration + if ( + this.currentToken && + this.tokenExpirationTime && + Date.now() < this.tokenExpirationTime - this.expirationBuffer + ) { + return this.currentToken; + } + + // If token doesn't exist or is expired/near expiration, fetch new token + try { + const newToken = await getKcpToken(); + this.currentToken = newToken; + // Set expiration time based on JWT expiry + // You'll need to decode the JWT to get the actual expiration + this.tokenExpirationTime = this.getExpirationFromJWT(newToken); + return newToken; + } catch (error) { + console.error('Failed to refresh token:', error); + throw error; + } + } + + getExpirationFromJWT(token) { + try { + // Split the token and get the payload + const PAYLOAD_INDEX = 1; + const payload = JSON.parse( + Buffer.from(token.split('.')[PAYLOAD_INDEX], 'base64').toString(), + ); + // exp is in seconds, convert to milliseconds + return payload.exp * 1000; + } catch (error) { + console.error('Error parsing JWT:', error); + // If we can't parse the expiration, set a default (e.g., 1 hour from now) + return Date.now() + 60 * 60 * 1000; + } + } +} diff --git a/backend/companion/companionRouter.js b/backend/companion/companionRouter.js new file mode 100644 index 0000000000..b363bf3172 --- /dev/null +++ b/backend/companion/companionRouter.js @@ -0,0 +1,72 @@ +import express from 'express'; +import { TokenManager } from './TokenManager'; + +const tokenManager = new TokenManager(); + +const router = express.Router(); + +router.use(express.json()); + +async function handleAIChatRequest(req, res) { + const { namespace, resourceType, groupVersion, resourceName } = JSON.parse( + req.body.toString(), + ); + const clusterUrl = req.headers['x-cluster-url']; + const certificateAuthorityData = + req.headers['x-cluster-certificate-authority-data']; + const clusterToken = req.headers['x-k8s-authorization']?.replace( + /^Bearer\s+/i, + '', + ); + const clientCertificateData = req.headers['x-client-certificate-data']; + const clientKeyData = req.headers['x-client-key-data']; + + try { + const url = 'https://companion.cp.dev.kyma.cloud.sap/api/conversations/'; + const payload = { + resource_kind: resourceType, + resource_api_version: groupVersion, + resource_name: resourceName, + namespace: namespace, + }; + + const AUTH_TOKEN = await tokenManager.getToken(); + + const headers = { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${AUTH_TOKEN}`, + 'X-Cluster-Certificate-Authority-Data': certificateAuthorityData, + 'X-Cluster-Url': clusterUrl, + }; + + if (clusterToken) { + headers['X-K8s-Authorization'] = clusterToken; + } else if (clientCertificateData && clientKeyData) { + headers['X-Client-Certificate-Data'] = clientCertificateData; + headers['X-Client-Key-Data'] = clientKeyData; + } else { + throw new Error('Missing authentication credentials'); + } + + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(payload), + }); + + const data = await response.json(); + + res.json({ + promptSuggestions: data?.initial_questions, + conversationId: data?.conversation_id, + }); + } catch (error) { + console.error('Error in AI Chat proxy:', error); + res.status(500).json({ error: 'Failed to fetch AI chat data' }); + } +} + +router.post('/suggestions', handleAIChatRequest); + +export default router; diff --git a/backend/companion/getKcpToken.js b/backend/companion/getKcpToken.js new file mode 100644 index 0000000000..c822fe75b7 --- /dev/null +++ b/backend/companion/getKcpToken.js @@ -0,0 +1,42 @@ +export async function getKcpToken() { + const tokenUrl = 'https://kymatest.accounts400.ondemand.com/oauth2/token'; + const grantType = 'client_credentials'; + const clientId = process.env.COMPANION_KCP_AUTH_CLIENT_ID; + const clientSecret = process.env.COMPANION_KCP_AUTH_CLIENT_SECRET; + + if (!clientId) { + throw new Error('COMPANION_KCP_AUTH_CLIENT_ID is not set'); + } + if (!clientSecret) { + throw new Error('COMPANION_KCP_AUTH_CLIENT_SECRET is not set'); + } + + // Prepare request data + const requestBody = new URLSearchParams(); + requestBody.append('grant_type', grantType); + + // Prepare authorization header + const authHeader = Buffer.from(`${clientId}:${clientSecret}`).toString( + 'base64', + ); + + try { + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { + Authorization: `Basic ${authHeader}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: requestBody.toString(), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data.access_token; + } catch (error) { + throw new Error(`Failed to fetch token: ${error.message}`); + } +} diff --git a/backend/index.js b/backend/index.js index 787140c92d..a6a3b82e81 100644 --- a/backend/index.js +++ b/backend/index.js @@ -1,6 +1,7 @@ import { makeHandleRequest, serveStaticApp, serveMonaco } from './common'; import { handleTracking } from './tracking.js'; import jsyaml from 'js-yaml'; +import companionRouter from './companion/companionRouter'; //import { requestLogger } from './utils/other'; //uncomment this to log the outgoing traffic const express = require('express'); @@ -88,11 +89,13 @@ if (isDocker) { // Running in dev mode // yup, order matters here serveMonaco(app); + app.use('/backend/ai-chat', companionRouter); app.use('/backend', handleRequest); serveStaticApp(app, '/', '/core-ui'); } else { // Running in prod mode handleTracking(app); + app.use('/backend/ai-chat', companionRouter); app.use('/backend', handleRequest); } diff --git a/public/i18n/en.yaml b/public/i18n/en.yaml index b23c60b329..02caadaa5d 100644 --- a/public/i18n/en.yaml +++ b/public/i18n/en.yaml @@ -766,6 +766,7 @@ kyma-companion: title: Service is interrupted subtitle: A temporary interruption occured. Please try again. introduction: Hi, I am your Kyma assistant! You can ask me any question, and I will try to help you to the best of my abilities. Meanwhile, you can check the suggested questions below; you may find them helpful! + introduction-no-suggestions: Hi, I am your Kyma assistant! You can ask me any question, and I will try to help you to the best of my abilities. While I don't have any initial suggestions for this resource, feel free to ask me anything you'd like! placeholder: Message Joule disclaimer: Joule is powered by generative AI, and the output must be reviewed before use. Do not enter any sensitive personal data, and avoid entering any other data you do not wish to be processed. tabs: diff --git a/src/command-pallette/CommandPalletteUI/useSearchResults.tsx b/src/command-pallette/CommandPalletteUI/useSearchResults.tsx index ca040d30fb..e9e209c500 100644 --- a/src/command-pallette/CommandPalletteUI/useSearchResults.tsx +++ b/src/command-pallette/CommandPalletteUI/useSearchResults.tsx @@ -56,6 +56,7 @@ export function useSearchResults({ const navigateAndCloseColumns = (to: To) => { setLayoutColumn({ layout: 'OneColumn', + startColumn: null, midColumn: null, endColumn: null, }); diff --git a/src/components/App/ExtensibilityRoutes.js b/src/components/App/ExtensibilityRoutes.js index eadef0c516..d4328702ee 100644 --- a/src/components/App/ExtensibilityRoutes.js +++ b/src/components/App/ExtensibilityRoutes.js @@ -36,20 +36,37 @@ const ColumnWrapper = ({ const { namespaceId, resourceName } = useParams(); const initialLayoutState = layout ? { - layout: layout ?? layoutState?.layout, + layout: layout, + startColumn: { + resourceName: null, + resourceType: urlPath ?? resourceType, + namespaceId: namespaceId, + apiGroup: extension?.general.resource.group, + apiVersion: extension?.general.resource.version, + }, midColumn: { resourceName: resourceName, resourceType: urlPath ?? resourceType, namespaceId: namespaceId, + apiGroup: extension?.general.resource.group, + apiVersion: extension?.general.resource.version, }, endColumn: null, } - : null; + : { + layout: layoutState?.layout, + startColumn: { + resourceType: urlPath ?? resourceType, + namespaceId: namespaceId, + apiGroup: extension?.general.resource.group, + apiVersion: extension?.general.resource.version, + }, + midColumn: null, + endColumn: null, + }; useEffect(() => { - if (layout && resourceName && resourceType) { - setLayoutColumn(initialLayoutState); - } + setLayoutColumn(initialLayoutState); // eslint-disable-next-line react-hooks/exhaustive-deps }, [layout, namespaceId, resourceName, resourceType]); diff --git a/src/components/BusolaExtensions/BusolaExtensionCreate.js b/src/components/BusolaExtensions/BusolaExtensionCreate.js index 171dffe70f..bcfd15840b 100644 --- a/src/components/BusolaExtensions/BusolaExtensionCreate.js +++ b/src/components/BusolaExtensions/BusolaExtensionCreate.js @@ -63,6 +63,11 @@ export default function BusolaExtensionCreate({ setLayoutColumn({ layout: nextLayout, showCreate: null, + startColumn: { + resourceName: null, + resourceType: 'Extensions', + namespaceId: 'kube-public', + }, midColumn: { resourceName: crd.metadata.name, resourceType: 'Extensions', diff --git a/src/components/BusolaExtensions/extensionsNode.ts b/src/components/BusolaExtensions/extensionsNode.ts index b680521c81..73abaddf79 100644 --- a/src/components/BusolaExtensions/extensionsNode.ts +++ b/src/components/BusolaExtensions/extensionsNode.ts @@ -3,6 +3,7 @@ import { configFeaturesNames, NavNode } from 'state/types'; export const extensionsNavNode: NavNode = { category: 'Configuration', resourceType: 'configmaps', + resourceTypeCased: 'ConfigMap', pathSegment: 'busolaextensions', label: 'Extensions', namespaced: false, diff --git a/src/components/Clusters/views/ClusterList.js b/src/components/Clusters/views/ClusterList.js index adb2e2d369..4f0cda0721 100644 --- a/src/components/Clusters/views/ClusterList.js +++ b/src/components/Clusters/views/ClusterList.js @@ -50,6 +50,7 @@ function ClusterList() { useEffect(() => { setLayoutColumn({ layout: 'OneColumn', + startColumn: null, midColumn: null, endColumn: null, }); diff --git a/src/components/Clusters/views/ClusterOverview/ClusterOverview.js b/src/components/Clusters/views/ClusterOverview/ClusterOverview.js index 232d9addae..60f05cd720 100644 --- a/src/components/Clusters/views/ClusterOverview/ClusterOverview.js +++ b/src/components/Clusters/views/ClusterOverview/ClusterOverview.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useFeature } from 'hooks/useFeature'; import { useNavigate } from 'react-router-dom'; @@ -19,6 +19,7 @@ import ClusterStats from './ClusterStats'; import ClusterDetails from './ClusterDetails'; import YamlUploadDialog from 'resources/Namespaces/YamlUpload/YamlUploadDialog'; import BannerCarousel from 'components/Extensibility/components/FeaturedCard/BannerCarousel'; +import { columnLayoutState } from 'state/columnLayoutAtom'; import './ClusterOverview.scss'; @@ -39,6 +40,18 @@ export function ClusterOverview() { }); const setShowAdd = useSetRecoilState(showYamlUploadDialogState); + const setLayoutColumn = useSetRecoilState(columnLayoutState); + useEffect(() => { + setLayoutColumn({ + layout: 'OneColumn', + startColumn: { + resourceType: 'Cluster', + }, + midColumn: null, + endColumn: null, + }); + }, [setLayoutColumn]); + const actions = [