Skip to content

Commit

Permalink
feat(ui): argocd deeplink
Browse files Browse the repository at this point in the history
Signed-off-by: Mayursinh Sarvaiya <[email protected]>
  • Loading branch information
Marvin9 committed Dec 13, 2024
1 parent 3adcf8b commit 5ea99a5
Show file tree
Hide file tree
Showing 11 changed files with 174 additions and 79 deletions.
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 UiPlugins = 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/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: 'Extra',
render: (_, promotion) => {
render: (_, promotion, promotionIndex) => {
const UiPlugins = 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 (UiPlugins?.length > 0) {
return (
<UiPluginHoles.DeepLinks.Promotion className='w-fit'>
{UiPlugins.map(
(ApplyPlugin, idx) => ApplyPlugin && <ApplyPlugin key={idx} promotion={promotion} />
(ApplyPlugin, idx) =>
ApplyPlugin && (
<ApplyPlugin
key={idx}
promotion={promotion}
unstable_argocdShardUrl={argocdShard?.url}
isLatestPromotion={promotionIndex === 0}
/>
)
)}
</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
95 changes: 95 additions & 0 deletions ui/src/plugins/argocd-plugin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { faChevronDown } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Dropdown, Space, Tooltip } from 'antd';

import { getPromotionHealthCheckConfig } from './plugin-helper';
import { PluginsInstallation } from './plugin-interfaces';

const argocdPlugin: PluginsInstallation = {
DeepLinkPlugin: {
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 argocdPlugin;
3 changes: 2 additions & 1 deletion ui/src/plugins/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import argocdPlugin from './argocd-plugin';
import { PluginsInstallation } from './plugin-interfaces';
import prPlugin from './pr-plugin';

const plugins: PluginsInstallation[] = [prPlugin];
const plugins: PluginsInstallation[] = [prPlugin, argocdPlugin];

export default plugins;
12 changes: 11 additions & 1 deletion ui/src/plugins/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/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
4 changes: 2 additions & 2 deletions ui/src/plugins/pr-plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ const getPullRequestLink = (
};

// TODO: refactor as it is getting bigger
const plugin: PluginsInstallation = {
const prPlugin: PluginsInstallation = {
DeepLinkPlugin: {
PromotionStep: {
shouldRender({ step, result }) {
Expand Down Expand Up @@ -153,4 +153,4 @@ const plugin: PluginsInstallation = {
}
};

export default plugin;
export default prPlugin;
Loading

0 comments on commit 5ea99a5

Please sign in to comment.