Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
73f8fe0
feat: track currently opened resource in ResourceDetails
chriskari Feb 12, 2025
332059b
feat: use columnLayoutState to track currentResource
chriskari Feb 13, 2025
f0392fb
Merge branch 'main' of https://github.com/kyma-project/busola into po…
chriskari Feb 13, 2025
9c803ac
feat: update structure of api call
chriskari Feb 13, 2025
fb6c722
Merge branch 'main' of https://github.com/kyma-project/busola into po…
chriskari Feb 13, 2025
d5193c0
feat: proxy API request through our backend
chriskari Feb 17, 2025
74fa194
Merge branch 'main' of https://github.com/kyma-project/busola into po…
chriskari Feb 17, 2025
701547f
Merge branch 'main' of https://github.com/kyma-project/busola into po…
chriskari Feb 19, 2025
47fc2e4
feat: automate token fetching in the backend
chriskari Feb 19, 2025
4a614e8
feat: enhance columnLayouState to also track the start column resource
chriskari Feb 19, 2025
fb7da47
feat: adjust resourcetype of cluster overview
chriskari Feb 19, 2025
6edb6f0
Merge branch 'main' of https://github.com/kyma-project/busola into po…
chriskari Feb 20, 2025
4716f7a
feat: improve initial layout
chriskari Feb 20, 2025
102719b
feat: adjust column state for cluster overview
chriskari Feb 20, 2025
55fd928
Merge branch 'main' of https://github.com/kyma-project/busola into po…
chriskari Feb 27, 2025
6bff107
feat: add loading indicator, and refresh when navigate to different r…
chriskari Feb 28, 2025
0d1f772
feat: handle different auth method and fix errors regarding this
chriskari Feb 28, 2025
c98b2b8
feat: skip request if conversation has started
chriskari Feb 28, 2025
837f50b
feat: add properly cased resourceType to navigation nodes
chriskari Feb 28, 2025
d7bdadc
feat: adjustments regarding previous commit
chriskari Feb 28, 2025
0441366
Merge branch 'main' of https://github.com/kyma-project/busola into po…
chriskari Mar 3, 2025
4c56eb6
feat: adjust introductory message based on existence of suggestions
chriskari Mar 3, 2025
e0cd17b
Merge branch 'main' of https://github.com/kyma-project/busola into po…
chriskari Mar 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions backend/companion/TokenManager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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 = JSON.parse(
Buffer.from(token.split('.')[1], 'base64').toString(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use const like PAYLOAD_INDEX instead of magic number?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okay

);
// 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;
}
}
}
60 changes: 60 additions & 0 deletions backend/companion/companionRouter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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 clusterToken = req.headers['x-k8s-authorization'].replace(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is regex required to use?

Copy link
Contributor Author

@chriskari chriskari Feb 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes this is the cleanest approach

/^Bearer\s+/i,
'',
);
const certificateAuthorityData =
req.headers['x-cluster-certificate-authority-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 response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${AUTH_TOKEN}`,
'X-Cluster-Certificate-Authority-Data': certificateAuthorityData,
'X-Cluster-Url': clusterUrl,
'X-K8s-Authorization': clusterToken,
},
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;
42 changes: 42 additions & 0 deletions backend/companion/getKcpToken.js
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
3 changes: 3 additions & 0 deletions backend/index.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export function useSearchResults({
const navigateAndCloseColumns = (to: To) => {
setLayoutColumn({
layout: 'OneColumn',
startColumn: null,
midColumn: null,
endColumn: null,
});
Expand Down
27 changes: 22 additions & 5 deletions src/components/App/ExtensibilityRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down
5 changes: 5 additions & 0 deletions src/components/BusolaExtensions/BusolaExtensionCreate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/components/Clusters/views/ClusterList.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ function ClusterList() {
useEffect(() => {
setLayoutColumn({
layout: 'OneColumn',
startColumn: null,
midColumn: null,
endColumn: null,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';

Expand All @@ -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 = [
<Button
key="upload-yaml"
Expand Down
61 changes: 26 additions & 35 deletions src/components/KymaCompanion/api/getPromptSuggestions.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,44 @@
import { getClusterConfig } from 'state/utils/getBackendInfo';
import { extractApiGroup } from 'resources/Roles/helpers';
import { PostFn } from 'shared/hooks/BackendAPI/usePost';

interface GetPromptSuggestionsParams {
post: PostFn;
namespace?: string;
resourceType?: string;
resourceType: string;
groupVersion?: string;
resourceName?: string;
sessionID?: string;
clusterUrl: string;
token: string;
certificateAuthorityData: string;
}

// TODO add return type
interface PromptSuggestionsResponse {
promptSuggestions: string[];
conversationId: string;
}

export default async function getPromptSuggestions({
post,
namespace = '',
resourceType = '',
groupVersion = '',
resourceName = '',
sessionID = '',
clusterUrl,
token,
certificateAuthorityData,
}: GetPromptSuggestionsParams): Promise<any[] | false> {
}: GetPromptSuggestionsParams): Promise<PromptSuggestionsResponse | false> {
try {
const { backendAddress } = getClusterConfig();
const url = `${backendAddress}/api/v1/namespaces/ai-core/services/http:ai-backend-clusterip:5000/proxy/api/v1/llm/init`;
const apiGroup = extractApiGroup(groupVersion);
const payload = JSON.parse(
`{"resource_type":"${resourceType.toLowerCase()}${
apiGroup.length ? `.${apiGroup}` : ''
}","resource_name":"${resourceName}","namespace":"${namespace}","session_id":"${sessionID}"}`,
);
const k8sAuthorization = `Bearer ${token}`;
const response = await post('/ai-chat/suggestions', {
namespace,
resourceType,
groupVersion,
resourceName,
});

if (
response &&
typeof response === 'object' &&
Array.isArray(response.promptSuggestions) &&
typeof response.conversationId === 'string'
) {
return response as PromptSuggestionsResponse;
}

let { results } = await fetch(url, {
headers: {
accept: 'application/json',
'content-type': 'application/json',
'X-Cluster-Certificate-Authority-Data': certificateAuthorityData,
'X-Cluster-Url': clusterUrl,
'X-K8s-Authorization': k8sAuthorization,
'X-User': sessionID,
},
body: JSON.stringify(payload),
method: 'POST',
}).then(result => result.json());
return results;
console.error('Invalid response format:', response);
return false;
} catch (error) {
console.error('Error fetching data:', error);
return false;
Expand Down
Loading
Loading