Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ui): Argo CD deep link in Promotion lists #3138

Merged
merged 9 commits into from
Dec 18, 2024
8 changes: 7 additions & 1 deletion ui/src/features/stage/promotion-details-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,13 @@ const Step = ({
};

const filteredUiPlugins = uiPlugins
.filter((plugin) => plugin.DeepLinkPlugin?.PromotionStep?.shouldRender({ step, result }))
.filter((plugin) =>
plugin.DeepLinkPlugin?.PromotionStep?.shouldRender({
step,
result,
output: output as Record<string, unknown>
})
)
.map((plugin) => plugin.DeepLinkPlugin?.PromotionStep?.render);

return {
Expand Down
23 changes: 18 additions & 5 deletions ui/src/features/stage/promotions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@ import {
listPromotions
} from '@ui/gen/service/v1alpha1/service-KargoService_connectquery';
import { KargoService } from '@ui/gen/service/v1alpha1/service_connect';
import { ListPromotionsResponse } from '@ui/gen/service/v1alpha1/service_pb';
import { ArgoCDShard, ListPromotionsResponse } from '@ui/gen/service/v1alpha1/service_pb';
import { Freight, Promotion } from '@ui/gen/v1alpha1/generated_pb';
import uiPlugins from '@ui/plugins';
import { UiPluginHoles } from '@ui/plugins/atoms/ui-plugin-hole/ui-plugin-holes';

import { PromotionDetailsModal } from './promotion-details-modal';
import { hasAbortRequest, promotionCompareFn } from './utils/promotion';

export const Promotions = () => {
export const Promotions = ({ argocdShard }: { argocdShard?: ArgoCDShard }) => {
const client = useQueryClient();

const { name: projectName, stageName } = useParams();
Expand Down Expand Up @@ -176,16 +176,29 @@ export const Promotions = () => {
},
{
title: '',
render: (_, promotion) => {
render: (_, promotion, promotionIndex) => {
const filteredUiPlugins = uiPlugins
.filter((plugin) => plugin.DeepLinkPlugin?.Promotion?.shouldRender({ promotion }))
.filter((plugin) =>
plugin.DeepLinkPlugin?.Promotion?.shouldRender({
promotion,
isLatestPromotion: promotionIndex === 0
})
)
.map((plugin) => plugin.DeepLinkPlugin?.Promotion?.render);

if (filteredUiPlugins?.length > 0) {
return (
<UiPluginHoles.DeepLinks.Promotion className='w-fit'>
{filteredUiPlugins.map(
(ApplyPlugin, idx) => ApplyPlugin && <ApplyPlugin key={idx} promotion={promotion} />
(ApplyPlugin, idx) =>
ApplyPlugin && (
<ApplyPlugin
key={idx}
promotion={promotion}
isLatestPromotion={promotionIndex === 0}
unstable_argocdShardUrl={argocdShard?.url}
/>
)
)}
</UiPluginHoles.DeepLinks.Promotion>
);
Expand Down
57 changes: 2 additions & 55 deletions ui/src/features/stage/stage-actions.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,21 @@
import { createConnectQueryKey, useMutation, useQuery } from '@connectrpc/connect-query';
import { createConnectQueryKey, useMutation } from '@connectrpc/connect-query';
import {
faChevronDown,
faExclamationCircle,
faExternalLinkAlt,
faPen,
faRedo,
faRefresh,
faTrash
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useQueryClient } from '@tanstack/react-query';
import { Button, Dropdown, Space, Tooltip } from 'antd';
import { Button, Space } from 'antd';
import React from 'react';
import { generatePath, useNavigate, useParams } from 'react-router-dom';

import { paths } from '@ui/config/paths';
import {
abortVerification,
deleteStage,
getConfig,
queryFreight,
refreshStage,
reverify
Expand All @@ -28,7 +25,6 @@ import { Stage } from '@ui/gen/v1alpha1/generated_pb';
import { useConfirmModal } from '../common/confirm-modal/use-confirm-modal';
import { useModal } from '../common/modal/use-modal';
import { currentFreightHasVerification } from '../common/utils';
import { getPromotionArgoCDApps } from '../promotion-directives/utils';

import { EditStageModal } from './edit-stage-modal';

Expand Down Expand Up @@ -83,62 +79,13 @@ export const StageActions = ({
}
}, [stage, shouldRefetchFreights]);

const { data: config } = useQuery(getConfig);
const argoCDAppsLinks = React.useMemo(() => {
const shardKey = stage?.metadata?.labels['kargo.akuity.io/shard'] || '';
const shard = config?.argocdShards?.[shardKey];

const argocdApps = getPromotionArgoCDApps(stage);

if (!shard || !argocdApps.length) {
return [];
}

return argocdApps.map((appName) => ({
label: appName,
url: `${shard.url}/applications/${shard.namespace}/${appName}`
}));
}, [config, stage]);

const { mutate: reverifyStage, isPending } = useMutation(reverify);
const { mutate: abortVerificationAction } = useMutation(abortVerification);

const verificationEnabled = stage?.spec?.verification;

return (
<Space size={16}>
{argoCDAppsLinks.length === 1 && (
<Tooltip title={argoCDAppsLinks[0]?.label}>
<Button
type='link'
onClick={() => window.open(argoCDAppsLinks[0]?.url, '_blank', 'noreferrer')}
size='small'
icon={<FontAwesomeIcon icon={faExternalLinkAlt} />}
>
Argo CD
</Button>
</Tooltip>
)}
{argoCDAppsLinks.length > 1 && (
<Dropdown
menu={{
items: argoCDAppsLinks.map((item, i) => ({
label: (
<a href={item?.url} target='_blank' rel='noreferrer' className='flex items-center'>
<FontAwesomeIcon icon={faExternalLinkAlt} className='mr-2' />
{item?.label}
</a>
),
key: i
}))
}}
trigger={['click']}
>
<Button type='link' size='small' icon={<FontAwesomeIcon icon={faChevronDown} />}>
Argo CD
</Button>
</Dropdown>
)}
{currentFreightHasVerification(stage) && (
<>
{verificationEnabled && (
Expand Down
9 changes: 8 additions & 1 deletion ui/src/features/stage/stage-details.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { useQuery } from '@connectrpc/connect-query';
import { Divider, Drawer, Tabs, Typography } from 'antd';
import moment from 'moment';
import { useMemo, useState } from 'react';
import { generatePath, useNavigate, useParams } from 'react-router-dom';

import { paths } from '@ui/config/paths';
import { HealthStatusIcon } from '@ui/features/common/health-status/health-status-icon';
import { getConfig } from '@ui/gen/service/v1alpha1/service-KargoService_connectquery';
import { Stage, VerificationInfo } from '@ui/gen/v1alpha1/generated_pb';

import { Description } from '../common/description';
Expand Down Expand Up @@ -42,6 +44,11 @@ export const StageDetails = ({ stage }: { stage: Stage }) => {
.sort((a, b) => moment(b.startTime?.toDate()).diff(moment(a.startTime?.toDate())));
}, [stage]);

const { data: config } = useQuery(getConfig);

const shardKey = stage?.metadata?.labels['kargo.akuity.io/shard'] || '';
const argocdShard = config?.argocdShards?.[shardKey];

return (
<Drawer open={!!stageName} onClose={onClose} width={'80%'} closable={false}>
{stage && (
Expand Down Expand Up @@ -79,7 +86,7 @@ export const StageDetails = ({ stage }: { stage: Stage }) => {
{
key: '1',
label: 'Promotions',
children: <Promotions />
children: <Promotions argocdShard={argocdShard} />
},
{
key: '2',
Expand Down
11 changes: 11 additions & 0 deletions ui/src/plugins/argocd-plugin/argocd-plugin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { PluginsInstallation } from '../atoms/plugin-interfaces';

import promotionDeepLinkPlugin from './deep-link/promotion';

const plugin: PluginsInstallation = {
DeepLinkPlugin: {
Promotion: promotionDeepLinkPlugin
}
};

export default plugin;
91 changes: 91 additions & 0 deletions ui/src/plugins/argocd-plugin/deep-link/promotion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { faChevronDown } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Dropdown, Space, Tooltip } from 'antd';

import { getPromotionHealthCheckConfig } from '@ui/plugins/atoms/plugin-helper';
import { DeepLinkPluginsInstallation } from '@ui/plugins/atoms/plugin-interfaces';

const plugin: DeepLinkPluginsInstallation['Promotion'] = {
shouldRender(opts) {
return (
Boolean(opts?.isLatestPromotion) &&
Boolean(opts.promotion?.spec?.steps?.find((step) => step?.uses === 'argocd-update')) &&
(opts.promotion?.status?.healthChecks?.length || 0) > 0
);
},
render(props) {
if (!props.unstable_argocdShardUrl) {
return (
<Tooltip title='Unknown ArgoCD shard' className='cursor-pointer text-xs text-gray-400'>
ArgoCD
</Tooltip>
);
}

// argocd shards sometimes might have base path included
// in those cases, we must not omit those pathname in order to have valid
const unstable_argocdShardUrl = props.unstable_argocdShardUrl.endsWith('/')
? props.unstable_argocdShardUrl.slice(0, -1)
: props.unstable_argocdShardUrl;

const healthChecks = (props.promotion?.status?.healthChecks || []).filter(
(hc) => hc?.uses === 'argocd-update'
);

// health checks contains nested apps
// ie. healthChecks:
// - uses: argocd-update
// config:
// apps:
// - name: app
// namespace: ns
const apps = [];

for (const healthCheck of healthChecks) {
const healthCheckConfig = getPromotionHealthCheckConfig(healthCheck);

// @ts-expect-error we don't have type but as long as we are sure whats coming in.. its safe to assume
for (const app of healthCheckConfig?.apps || []) {
apps.push(app);
}
}

if (apps.length === 1) {
return (
<a
target='_blank'
href={`${unstable_argocdShardUrl}/applications/${apps[0].namespace}/${apps[0].name}`}
>
ArgoCD
</a>
);
}

return (
<Dropdown
menu={{
items: apps.map((app, idx) => ({
key: idx,
label: (
<a
target='_blank'
href={`${unstable_argocdShardUrl}/applications/${app.namespace}/${app.name}`}
>
{app.namespace} - {app.name}
</a>
)
}))
}}
>
<a onClick={(e) => e.preventDefault()}>
<Space>
ArgoCD
<FontAwesomeIcon icon={faChevronDown} className='text-xs' />
</Space>
</a>
</Dropdown>
);
}
};

export default plugin;
12 changes: 11 additions & 1 deletion ui/src/plugins/atoms/plugin-helper.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Promotion, PromotionStep } from '@ui/gen/v1alpha1/generated_pb';
import { HealthCheckStep, Promotion, PromotionStep } from '@ui/gen/v1alpha1/generated_pb';
import { decodeRawData } from '@ui/utils/decode-raw-data';

export const getPromotionStepConfig = (step: PromotionStep): Record<string, unknown> =>
Expand All @@ -21,5 +21,15 @@ export const getPromotionState = (promotion: Promotion): Record<string, Record<s
})
);

export const getPromotionHealthCheckConfig = (hc: HealthCheckStep): Record<string, unknown> =>
JSON.parse(
decodeRawData({
result: {
case: 'raw',
value: hc?.config?.raw || new Uint8Array()
}
})
);

export const getPromotionStepAlias = (promotionStep: PromotionStep, stepIndex: string) =>
promotionStep?.as || `step-${stepIndex}`;
35 changes: 24 additions & 11 deletions ui/src/plugins/atoms/plugin-interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,27 +14,40 @@ import { ReactNode } from 'react';
import { PromotionDirectiveStepStatus } from '@ui/features/common/promotion-directive-step-status/utils';
import { Promotion, PromotionStep } from '@ui/gen/v1alpha1/generated_pb';

interface DeepLinksPluginProps {
PromotionStep: {
// step metadata
step: PromotionStep;
// flexible to render based on status
result: PromotionDirectiveStepStatus;
// output from steps
output?: Record<string, unknown>;
};

Promotion: {
promotion: Promotion;

// which argocd shard does this promotion affect
unstable_argocdShardUrl?: string;

isLatestPromotion?: boolean;
};
}

export interface DeepLinkPluginsInstallation {
// Scopes

// Plugin generates information from given plugin step and renders in the step view in Promotion steps modal
PromotionStep?: {
// thoughts.. instead of coupling this to name of step, this would open doors for plugin development based on combination of promotion steps
shouldRender: (opts: { step: PromotionStep; result: PromotionDirectiveStepStatus }) => boolean;
render: (props: {
// step metadata
step: PromotionStep;
// flexible to render based on status
result: PromotionDirectiveStepStatus;
// output from steps
output?: Record<string, unknown>;
}) => ReactNode;
shouldRender: (opts: DeepLinksPluginProps['PromotionStep']) => boolean;
render: (props: DeepLinksPluginProps['PromotionStep']) => ReactNode;
};

// Plugin summarises promotion and provide useful link(s) and renders in the promotion list view
Promotion?: {
shouldRender: (opts: { promotion: Promotion }) => boolean;
render: (props: { promotion: Promotion }) => ReactNode;
shouldRender: (opts: DeepLinksPluginProps['Promotion']) => boolean;
render: (props: DeepLinksPluginProps['Promotion']) => ReactNode;
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ export const DeepLinkPromotionStep = ({
return (
<PluginErrorBoundary>
<div
className={classNames(className, 'bg-gray-100 px-2 py-1 rounded-md text-sm w-fit')}
className={classNames(
className,
'bg-gray-100 px-2 py-1 rounded-md text-sm w-fit flex gap-2'
)}
onClick={(e) => {
// prevent opening the collapsible menu
e.stopPropagation();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const DeepLinkPromotion = ({
className
}: PropsWithChildren<{ className?: string }>) => (
<PluginErrorBoundary>
<div className={classNames(className, 'bg-gray-100 px-2 py-1 rounded-md text-sm')}>
<div className={classNames(className, 'bg-gray-100 px-2 py-1 rounded-md text-sm flex gap-3')}>
{children}
</div>
</PluginErrorBoundary>
Expand Down
Loading
Loading