Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,10 @@ export class OptimizationsClient implements OptimizationsApi {

// @public (undocumented)
export interface OrchestratorSlimApi {
// (undocumented)
checkWorkflowAvailability(
workflowId: string,
): Promise<WorkflowAvailabilityResult>;
// (undocumented)
executeWorkflow<D = JsonObject>(
workflowId: string,
Expand All @@ -429,6 +433,10 @@ export class OrchestratorSlimClient implements OrchestratorSlimApi {
identityApi: IdentityApi;
});
// (undocumented)
checkWorkflowAvailability(
workflowId: string,
): Promise<WorkflowAvailabilityResult>;
// (undocumented)
executeWorkflow<D = JsonObject>(
workflowId: string,
workflowInputData: D,
Expand Down Expand Up @@ -487,5 +495,22 @@ export interface Tag {
values: ProjectValue[];
}

// @public (undocumented)
export interface WorkflowAvailabilityResult {
// (undocumented)
available: boolean;
// (undocumented)
errorMessage?: string;
// (undocumented)
reason?: WorkflowUnavailableReason;
}

// @public (undocumented)
export type WorkflowUnavailableReason =
| 'not_configured'
| 'not_found'
| 'access_denied'
| 'service_unavailable';

// (No @packageDocumentation comment for this package)
```
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,10 @@ export class OptimizationsClient implements OptimizationsApi {

// @public (undocumented)
export interface OrchestratorSlimApi {
// (undocumented)
checkWorkflowAvailability(
workflowId: string,
): Promise<WorkflowAvailabilityResult>;
// (undocumented)
executeWorkflow<D = JsonObject>(
workflowId: string,
Expand All @@ -752,6 +756,10 @@ export class OrchestratorSlimClient implements OrchestratorSlimApi {
identityApi: IdentityApi;
});
// (undocumented)
checkWorkflowAvailability(
workflowId: string,
): Promise<WorkflowAvailabilityResult>;
// (undocumented)
executeWorkflow<D = JsonObject>(
workflowId: string,
workflowInputData: D,
Expand Down Expand Up @@ -1145,5 +1153,22 @@ export type TypedResponse<T> = Omit<Response, 'json'> & {
json: () => Promise<T>;
};

// @public (undocumented)
export interface WorkflowAvailabilityResult {
// (undocumented)
available: boolean;
// (undocumented)
errorMessage?: string;
// (undocumented)
reason?: WorkflowUnavailableReason;
}

// @public (undocumented)
export type WorkflowUnavailableReason =
| 'not_configured'
| 'not_found'
| 'access_denied'
| 'service_unavailable';

// (No @packageDocumentation comment for this package)
```
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,26 @@

import type { JsonObject } from '@backstage/types';

/** @public */
export type WorkflowUnavailableReason =
| 'not_configured'
| 'not_found'
| 'access_denied'
| 'service_unavailable';

/** @public */
export interface WorkflowAvailabilityResult {
available: boolean;
reason?: WorkflowUnavailableReason;
errorMessage?: string;
}

/** @public */
export interface OrchestratorSlimApi {
isWorkflowAvailable(workflowId: string): Promise<boolean>;
checkWorkflowAvailability(
workflowId: string,
): Promise<WorkflowAvailabilityResult>;
executeWorkflow<D = JsonObject>(
workflowId: string,
workflowInputData: D,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ import {
FetchApi,
IdentityApi,
} from '@backstage/core-plugin-api';
import type { OrchestratorSlimApi } from './OrchestratorSlimApi';
import type {
OrchestratorSlimApi,
WorkflowAvailabilityResult,
} from './OrchestratorSlimApi';
import type { JsonObject } from '@backstage/types';

/** @public */
Expand All @@ -40,6 +43,21 @@ export class OrchestratorSlimClient implements OrchestratorSlimApi {
}

async isWorkflowAvailable(workflowId: string): Promise<boolean> {
const result = await this.checkWorkflowAvailability(workflowId);
return result.available;
}

async checkWorkflowAvailability(
workflowId: string,
): Promise<WorkflowAvailabilityResult> {
if (!workflowId) {
return {
available: false,
reason: 'not_configured',
errorMessage: 'No workflow configured to apply recommendations',
};
}

if (!this.baseUrl) {
this.baseUrl = await this.discoveryApi.getBaseUrl('orchestrator');
}
Expand All @@ -48,15 +66,61 @@ export class OrchestratorSlimClient implements OrchestratorSlimApi {
workflowId,
)}/overview`;

let result = false;
try {
const response = await this.fetchApi.fetch(url, { method: 'GET' });
result = response.ok;
} catch {
// Carry on...
}

return result;
if (response.ok) {
return { available: true };
}

// Try to extract error message from the response
let errorMessage: string | undefined;
try {
const errorResponse = (await response.json()) as {
error?: { message?: string };
message?: string;
};
errorMessage =
errorResponse?.error?.message || errorResponse?.message || undefined;
} catch {
// Ignore JSON parsing errors
}

if (response.status === 404) {
return {
available: false,
reason: 'not_found',
errorMessage: errorMessage || 'Workflow not found',
};
}

if (response.status === 403 || response.status === 401) {
return {
available: false,
reason: 'access_denied',
errorMessage:
errorMessage ||
'You do not have permission to access this workflow',
};
}

return {
available: false,
reason: 'service_unavailable',
errorMessage:
errorMessage || 'Workflow service is currently unavailable',
};
} catch (err) {
const errorMessage =
err instanceof Error
? err.message
: 'Unable to connect to workflow service';
return {
available: false,
reason: 'service_unavailable',
errorMessage,
};
}
}

/** @public */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from '@backstage/core-components';
import { configApiRef, useApi } from '@backstage/core-plugin-api';
import type { RecommendationBoxPlots } from '@red-hat-developer-hub/plugin-redhat-resource-optimization-common/models';
import type { WorkflowUnavailableReason } from '@red-hat-developer-hub/plugin-redhat-resource-optimization-common/clients';
import { getTimeFromNow } from '../../utils/dates';
import { BasePage } from '../../components/BasePage';
import { type Interval, OptimizationType } from './models/ChartEnums';
Expand Down Expand Up @@ -143,18 +144,28 @@ export const OptimizationsBreakdownPage = () => {
'resourceOptimization.optimizationWorkflowId',
) ?? '',
);
const workflowUnavailableReasonRef = useRef<
WorkflowUnavailableReason | undefined
>(undefined);
const workflowErrorMessageRef = useRef<string | undefined>(undefined);
const orchestratorSlimApi = useApi(orchestratorSlimApiRef);
const optimizationsApi = useApi(optimizationsApiRef);
const {
value: recommendationsData,
loading,
error,
} = useAsync(async () => {
const isWorkflowAvailable = await orchestratorSlimApi.isWorkflowAvailable(
workflowIdRef.current,
);
if (!isWorkflowAvailable) {
const availabilityResult =
await orchestratorSlimApi.checkWorkflowAvailability(
workflowIdRef.current,
);
if (availabilityResult.available) {
workflowUnavailableReasonRef.current = undefined;
workflowErrorMessageRef.current = undefined;
} else {
workflowIdRef.current = '';
workflowUnavailableReasonRef.current = availabilityResult.reason;
workflowErrorMessageRef.current = availabilityResult.errorMessage;
}

const apiQuery = {
Expand Down Expand Up @@ -263,6 +274,8 @@ export const OptimizationsBreakdownPage = () => {
onRecommendationTermChange={handleRecommendationTermChange}
onApplyRecommendation={handleApplyRecommendation}
workflowId={workflowIdRef.current}
workflowUnavailableReason={workflowUnavailableReasonRef.current}
workflowErrorMessage={workflowErrorMessageRef.current}
/>
</TabbedLayout.Route>

Expand All @@ -280,6 +293,8 @@ export const OptimizationsBreakdownPage = () => {
onRecommendationTermChange={handleRecommendationTermChange}
onApplyRecommendation={handleApplyRecommendation}
workflowId={workflowIdRef.current}
workflowUnavailableReason={workflowUnavailableReasonRef.current}
workflowErrorMessage={workflowErrorMessageRef.current}
/>
</TabbedLayout.Route>
</TabbedLayout>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@
* limitations under the License.
*/

import React from 'react';
import { Button, Grid } from '@material-ui/core';
import React, { useMemo } from 'react';
import { Box, Button, Grid, Tooltip } from '@material-ui/core';
import type { WorkflowUnavailableReason } from '@red-hat-developer-hub/plugin-redhat-resource-optimization-common/clients';
import { RecommendationType } from '../../models/ChartEnums';
import { ChartInfoCard } from './components/chart-info-card/ChartInfoCard';
import { CodeInfoCard } from './components/CodeInfoCard';
Expand All @@ -25,16 +26,46 @@ type ContainerInfoCardProps = Parameters<typeof ContainerInfoCard>[0];
type CodeInfoCardProps = Parameters<typeof CodeInfoCard>[0];
type ChartInfoCardProps = Parameters<typeof ChartInfoCard>[0];

const DEFAULT_WORKFLOW_MESSAGES: Record<WorkflowUnavailableReason, string> = {
not_configured: 'No workflow configured to apply recommendations',
not_found: 'Workflow not found',
access_denied: 'You do not have permission to access this workflow',
service_unavailable: 'Workflow service is currently unavailable',
};

interface OptimizationEngineTabProps extends ContainerInfoCardProps {
currentConfiguration: CodeInfoCardProps['yamlCodeData'];
recommendedConfiguration: CodeInfoCardProps['yamlCodeData'];
chartData: ChartInfoCardProps['chartData'];
optimizationType: ChartInfoCardProps['optimizationType'];
onApplyRecommendation?: React.MouseEventHandler<HTMLButtonElement>;
workflowId?: string;
workflowUnavailableReason?: WorkflowUnavailableReason;
workflowErrorMessage?: string;
}

export const OptimizationEngineTab = (props: OptimizationEngineTabProps) => {
const isWorkflowAvailable = !!props.workflowId;

const tooltipMessage = useMemo(() => {
if (isWorkflowAvailable) {
return '';
}
// Prefer the actual error message from the API
if (props.workflowErrorMessage) {
return props.workflowErrorMessage;
}
// Fall back to default messages based on reason
if (props.workflowUnavailableReason) {
return DEFAULT_WORKFLOW_MESSAGES[props.workflowUnavailableReason];
}
return DEFAULT_WORKFLOW_MESSAGES.not_configured;
}, [
isWorkflowAvailable,
props.workflowErrorMessage,
props.workflowUnavailableReason,
]);

return (
<Grid container>
<Grid item xs={12}>
Expand All @@ -57,14 +88,23 @@ export const OptimizationEngineTab = (props: OptimizationEngineTabProps) => {
showCopyCodeButton
yamlCodeData={props.recommendedConfiguration}
action={
<Button
variant="contained"
color="primary"
onClick={props.onApplyRecommendation}
disabled={!props.workflowId}
<Tooltip
title={tooltipMessage}
disableHoverListener={isWorkflowAvailable}
disableFocusListener={isWorkflowAvailable}
disableTouchListener={isWorkflowAvailable}
>
Apply recommendation
</Button>
<Box component="span" display="inline-block">
<Button
variant="contained"
color="primary"
onClick={props.onApplyRecommendation}
disabled={!isWorkflowAvailable}
>
Apply recommendation
</Button>
</Box>
</Tooltip>
}
/>
</Grid>
Expand Down