From 6cc894aaa48c2cef55cfdbbd0aa48b55308cc3d7 Mon Sep 17 00:00:00 2001 From: Remington Breeze Date: Tue, 13 Dec 2022 13:07:39 -0800 Subject: [PATCH] feat: expose deep links in UI (#11680) Signed-off-by: Remington Breeze Signed-off-by: emirot --- .../application-node-info.tsx | 9 +++++ .../application-summary.tsx | 9 +++++ .../components/application-urls.tsx | 18 ++-------- .../resource-details/resource-details.tsx | 13 +++++-- ui/src/app/applications/components/utils.tsx | 29 ++++++++++++++-- .../project-details/project-details.tsx | 9 +++++ ui/src/app/shared/components/deep-links.tsx | 19 +++++++++++ ui/src/app/shared/models.ts | 11 ++++++ .../shared/services/applications-service.ts | 34 +++++++++++++++++++ .../app/shared/services/projects-service.ts | 7 ++++ ui/src/app/shared/utils.ts | 15 ++++++++ 11 files changed, 152 insertions(+), 21 deletions(-) create mode 100644 ui/src/app/shared/components/deep-links.tsx diff --git a/ui/src/app/applications/components/application-node-info/application-node-info.tsx b/ui/src/app/applications/components/application-node-info/application-node-info.tsx index 2a3e3fe0ae25c..17b7a5d853bff 100644 --- a/ui/src/app/applications/components/application-node-info/application-node-info.tsx +++ b/ui/src/app/applications/components/application-node-info/application-node-info.tsx @@ -4,6 +4,7 @@ import * as moment from 'moment'; import * as React from 'react'; import {YamlEditor, ClipboardText} from '../../../shared/components'; +import {DeepLinks} from '../../../shared/components/deep-links'; import * as models from '../../../shared/models'; import {services} from '../../../shared/services'; import {ResourceTreeNode} from '../application-resource-tree/application-resource-tree'; @@ -16,6 +17,7 @@ export const ApplicationNodeInfo = (props: { application: models.Application; node: models.ResourceNode; live: models.State; + links: models.LinksResponse; controlled: {summary: models.ResourceStatus; state: models.ResourceDiff}; }) => { const attributes: {title: string; value: any}[] = [ @@ -101,6 +103,13 @@ export const ApplicationNodeInfo = (props: { } } + if (props.links) { + attributes.push({ + title: 'LINKS', + value: + }); + } + const tabs: Tab[] = [ { key: 'manifest', diff --git a/ui/src/app/applications/components/application-summary/application-summary.tsx b/ui/src/app/applications/components/application-summary/application-summary.tsx index 4323f5df37515..f4bf986ba19d9 100644 --- a/ui/src/app/applications/components/application-summary/application-summary.tsx +++ b/ui/src/app/applications/components/application-summary/application-summary.tsx @@ -29,6 +29,7 @@ import {EditNotificationSubscriptions, useEditNotificationSubscriptions} from '. import {EditAnnotations} from './edit-annotations'; import './application-summary.scss'; +import {DeepLinks} from '../../../shared/components/deep-links'; function swap(array: any[], a: number, b: number) { array = array.slice(); @@ -293,6 +294,14 @@ export const ApplicationSummary = (props: ApplicationSummaryProps) => { {app.status.health.status} ) + }, + { + title: 'LINKS', + view: ( + services.applications.getLinks(app.metadata.name)} input={app} key='appLinks'> + {(links: models.LinksResponse) => } + + ) } ]; diff --git a/ui/src/app/applications/components/application-urls.tsx b/ui/src/app/applications/components/application-urls.tsx index 1d64bc8a43b9f..e6dc82458156d 100644 --- a/ui/src/app/applications/components/application-urls.tsx +++ b/ui/src/app/applications/components/application-urls.tsx @@ -1,5 +1,6 @@ import {DropDownMenu} from 'argo-ui'; import * as React from 'react'; +import {isValidURL} from '../../shared/utils'; export class InvalidExternalLinkError extends Error { constructor(message: string) { @@ -22,25 +23,10 @@ export class ExternalLink { this.title = url; this.ref = url; } - if (!ExternalLink.isValidURL(this.ref)) { + if (!isValidURL(this.ref)) { throw new InvalidExternalLinkError('Invalid URL'); } } - - private static isValidURL(url: string): boolean { - try { - const parsedUrl = new URL(url); - return parsedUrl.protocol !== 'javascript:' && parsedUrl.protocol !== 'data:'; - } catch (TypeError) { - try { - // Try parsing as a relative URL. - const parsedUrl = new URL(url, window.location.origin); - return parsedUrl.protocol !== 'javascript:' && parsedUrl.protocol !== 'data:'; - } catch (TypeError) { - return false; - } - } - } } export const ApplicationURLs = ({urls}: {urls: string[]}) => { diff --git a/ui/src/app/applications/components/resource-details/resource-details.tsx b/ui/src/app/applications/components/resource-details/resource-details.tsx index 7b677a8d322a3..1166ebff22378 100644 --- a/ui/src/app/applications/components/resource-details/resource-details.tsx +++ b/ui/src/app/applications/components/resource-details/resource-details.tsx @@ -285,7 +285,8 @@ export const ResourceDetails = (props: ResourceDetailsProps) => { const execEnabled = settings.execEnabled; const logsAllowed = await services.accounts.canI('logs', 'get', application.spec.project + '/' + application.metadata.name); const execAllowed = await services.accounts.canI('exec', 'create', application.spec.project + '/' + application.metadata.name); - return {controlledState, liveState, events, podState, execEnabled, execAllowed, logsAllowed}; + const links = await services.applications.getResourceLinks(application.metadata.name, application.metadata.namespace, selectedNode); + return {controlledState, liveState, events, podState, execEnabled, execAllowed, logsAllowed, links}; }}> {data => ( @@ -338,7 +339,15 @@ export const ResourceDetails = (props: ResourceDetailsProps) => { title: 'SUMMARY', icon: 'fa fa-file-alt', key: 'summary', - content: + content: ( + + ) } ], data.execEnabled, diff --git a/ui/src/app/applications/components/utils.tsx b/ui/src/app/applications/components/utils.tsx index 0a79754993539..9d5633bbaf7ec 100644 --- a/ui/src/app/applications/components/utils.tsx +++ b/ui/src/app/applications/components/utils.tsx @@ -26,7 +26,7 @@ export interface NodeId { export const ExternalLinkAnnotation = 'link.argocd.argoproj.io/external-link'; -type ActionMenuItem = MenuItem & {disabled?: boolean}; +type ActionMenuItem = MenuItem & {disabled?: boolean; tooltip?: string}; export function nodeKey(node: NodeId) { return [node.group, node.kind, node.namespace, node.name].join('/'); @@ -493,10 +493,23 @@ function getActionItems( const resourceActions = getResourceActionsMenuItems(resource, application.metadata, appContext); + const links = services.applications.getResourceLinks(application.metadata.name, application.metadata.namespace, resource).then(data => { + return (data.items || []).map( + link => + ({ + title: link.title, + iconClassName: `fa ${link.iconClass ? link.iconClass : 'fa-external-link'}`, + action: () => window.open(link.url, '_blank'), + tooltip: link.description + } as MenuItem) + ); + }); + return combineLatest( from([items]), // this resolves immediately concat([[] as MenuItem[]], resourceActions), // this resolves at first to [] and then whatever the API returns - concat([[] as MenuItem[]], execAction) // this resolves at first to [] and then whatever the API returns + concat([[] as MenuItem[]], execAction), // this resolves at first to [] and then whatever the API returns + concat([[] as MenuItem[]], links) // this resolves at first to [] and then whatever the API returns ).pipe(map(res => ([] as MenuItem[]).concat(...res))); } @@ -530,7 +543,17 @@ export function renderResourceMenu( document.body.click(); } }}> - {item.iconClassName && } {item.title} + {item.tooltip ? ( + +
+ {item.iconClassName && } {item.title} +
+
+ ) : ( + <> + {item.iconClassName && } {item.title} + + )} ))} diff --git a/ui/src/app/settings/components/project-details/project-details.tsx b/ui/src/app/settings/components/project-details/project-details.tsx index 7bed89c99e928..224c2e1e45e12 100644 --- a/ui/src/app/settings/components/project-details/project-details.tsx +++ b/ui/src/app/settings/components/project-details/project-details.tsx @@ -16,6 +16,7 @@ import {ProjectEvents} from '../project-events/project-events'; import {ProjectRoleEditPanel} from '../project-role-edit-panel/project-role-edit-panel'; import {ProjectSyncWindowsEditPanel} from '../project-sync-windows-edit-panel/project-sync-windows-edit-panel'; import {ResourceListsPanel} from './resource-lists-panel'; +import {DeepLinks} from '../../../shared/components/deep-links'; require('./project-details.scss'); @@ -575,6 +576,14 @@ export class ProjectDetails extends React.Component `${label}=${proj.metadata.labels[label]}`) .join(' '), edit: (formApi: FormApi) => + }, + { + title: 'LINKS', + view: ( +
+ services.projects.getLinks(proj.metadata.name)}>{links => } +
+ ) } ]} /> diff --git a/ui/src/app/shared/components/deep-links.tsx b/ui/src/app/shared/components/deep-links.tsx new file mode 100644 index 0000000000000..f7881bff537d9 --- /dev/null +++ b/ui/src/app/shared/components/deep-links.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import {LinkInfo} from '../models'; + +export const DeepLinks = (props: {links: LinkInfo[]}) => { + const {links} = props; + return ( +
+ {(links || []).map((link: LinkInfo) => ( +
+ + +
{link.title}
+
+ {link.description && <>({link.description})} +
+ ))} +
+ ); +}; diff --git a/ui/src/app/shared/models.ts b/ui/src/app/shared/models.ts index b65e123c04368..37ec6b44ddbcc 100644 --- a/ui/src/app/shared/models.ts +++ b/ui/src/app/shared/models.ts @@ -921,3 +921,14 @@ export enum PodPhase { export interface NotificationChunk { name: string; } + +export interface LinkInfo { + title: string; + url: string; + description?: string; + iconClass?: string; +} + +export interface LinksResponse { + items: LinkInfo[]; +} diff --git a/ui/src/app/shared/services/applications-service.ts b/ui/src/app/shared/services/applications-service.ts index 006e6c558b97f..c4d244f957726 100644 --- a/ui/src/app/shared/services/applications-service.ts +++ b/ui/src/app/shared/services/applications-service.ts @@ -3,6 +3,7 @@ import {Observable} from 'rxjs'; import {map, repeat, retry} from 'rxjs/operators'; import * as models from '../models'; +import {isValidURL} from '../utils'; import requests from './requests'; interface QueryOptions { @@ -390,6 +391,39 @@ export class ApplicationsService { .then(() => true); } + public getLinks(applicationName: string): Promise { + return requests + .get(`/applications/${applicationName}/links`) + .send() + .then(res => res.body as models.LinksResponse); + } + + public getResourceLinks(applicationName: string, appNamespace: string, resource: models.ResourceNode): Promise { + return requests + .get(`/applications/${applicationName}/resource/links`) + .query({ + name: resource.name, + appNamespace, + namespace: resource.namespace, + resourceName: resource.name, + version: resource.version, + kind: resource.kind, + group: resource.group || '' // The group query param must be present even if empty. + }) + .send() + .then(res => { + const links = res.body as models.LinksResponse; + const items: models.LinkInfo[] = []; + (links?.items || []).forEach(link => { + if (isValidURL(link.url)) { + items.push(link); + } + }); + links.items = items; + return links; + }); + } + private getLogsQuery( namespace: string, appNamespace: string, diff --git a/ui/src/app/shared/services/projects-service.ts b/ui/src/app/shared/services/projects-service.ts index ba6a4ecbce5e8..144939494cdad 100644 --- a/ui/src/app/shared/services/projects-service.ts +++ b/ui/src/app/shared/services/projects-service.ts @@ -172,4 +172,11 @@ export class ProjectsService { .send() .then(res => (res.body as models.EventList).items || []); } + + public getLinks(projectName: string): Promise { + return requests + .get(`/projects/${projectName}/links`) + .send() + .then(res => res.body as models.LinksResponse); + } } diff --git a/ui/src/app/shared/utils.ts b/ui/src/app/shared/utils.ts index 6091cebe12355..c57715a8f933d 100644 --- a/ui/src/app/shared/utils.ts +++ b/ui/src/app/shared/utils.ts @@ -19,3 +19,18 @@ export function concatMaps(...maps: (Map | null)[]): Map