Add Azure CLI command to Airlock Files Section (#3196)

* replace invalid statuses with valid statuses - exclude submitted, cancelled, and other in_progresses statuses

* move the logic of the airlock request files section to its own file

* add pivot table for switching between file upload options

* add CLI section to file upload options (no styling to command yet)

* fix cli command text box height

* add styling and finishes to the cli command

* move the logic of the  cli command to its own component

* improve regex for matching all forms of cli commands

* add comments to explain the regexes

* remove redundant pattern param from cli command

* dependabot PR #3192

* update changelog and version

* remove SAS URL reference from info message

* change comment in upload cli command

* Update ui/app/src/components/shared/airlock/AirlockRequestFilesSection.tsx

Co-authored-by: James Griffin <me@JamesGriff.in>

* fix CR comments

* remove generate link button

* add tooltip to copy SAS URL button

---------

Co-authored-by: James Griffin <me@JamesGriff.in>
This commit is contained in:
Yuval Yaron 2023-02-06 19:28:13 +02:00 коммит произвёл GitHub
Родитель d3fae422e2
Коммит 15d9f27cb7
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
7 изменённых файлов: 230 добавлений и 63 удалений

Просмотреть файл

@ -25,6 +25,7 @@
FEATURES:
* Add Azure Databricks as workspace service [#1857](https://github.com/microsoft/AzureTRE/pull/1857)
* (UI) Added the option to upload/download files to airlock requests via Azure CLI ([#3196](https://github.com/microsoft/AzureTRE/pull/3196))
ENHANCEMENTS:
* Add support for referencing IP Groups from the Core Resource Group in firewall rules created via the pipeline [#3089](https://github.com/microsoft/AzureTRE/pull/3089)

Просмотреть файл

@ -1,6 +1,6 @@
{
"name": "tre-ui",
"version": "0.3.1",
"version": "0.4.0",
"private": true,
"dependencies": {
"@azure/msal-browser": "^2.32.1",

Просмотреть файл

@ -0,0 +1,78 @@
import { IconButton, Spinner, Stack, TooltipHost } from "@fluentui/react";
import React, { useState } from "react";
import { Text } from '@fluentui/react/lib/Text';
interface CliCommandProps {
command: string,
title: string,
isLoading: boolean
}
export const CliCommand: React.FunctionComponent<CliCommandProps> = (props: CliCommandProps) => {
const COPY_TOOL_TIP_DEFAULT_MESSAGE = "Copy to clipboard"
const [copyToolTipMessage, setCopyToolTipMessage] = useState<string>(COPY_TOOL_TIP_DEFAULT_MESSAGE);
const handleCopyCommand = () => {
navigator.clipboard.writeText(props.command);
setCopyToolTipMessage("Copied")
setTimeout(() => setCopyToolTipMessage(COPY_TOOL_TIP_DEFAULT_MESSAGE), 3000);
}
const renderCommand = () => {
// regex to match only the command part (without the parameters)
const commandMatches = props.command.match(/^((?! -).)*/);
if (!commandMatches) {
return
}
const commandWithoutParams = commandMatches[0]
const paramsOnly = props.command.replace(commandWithoutParams, '')
// regex to match all the parameters, along with their assigned values
const paramsList = paramsOnly.match(/(?<= )-{1,2}[\w-]+(?:(?!( -){1,2}).)*/g)
return <Stack styles={{ root: { padding: "15px", backgroundColor: "#f2f2f2", border: '1px solid #e6e6e6' } }}>
<code style={{ color: "blue", fontSize: "13px" }}>
{commandWithoutParams}
</code>
<Stack.Item style={{ paddingLeft: "30px" }}>
{paramsList?.map((paramWithValue) => {
// split the parameter from it's value
const splitParam = paramWithValue.split(/\s(.*)/)
const param = splitParam[0];
const paramValue = ` ${splitParam[1] || ''}`;
const paramValueIsComment = paramValue?.match(/<.*?>/);
return (
<div style={{ wordBreak: "break-all", fontSize: "13px" }}>
<code style={{ color: "teal" }}>{param}</code>
<code style={{ color: paramValueIsComment ? "firebrick" : "black" }}>{paramValue}</code>
</div>
);
})}
</Stack.Item>
</Stack >
}
return (
<Stack>
<Stack horizontal style={{ backgroundColor: "#e6e6e6", alignItems: 'center' }}>
<Stack.Item grow style={{ paddingLeft: "10px", height: "100%" }}>
<Text >{props.title}</Text>
</Stack.Item>
<Stack.Item align="end">
<TooltipHost content={copyToolTipMessage}>
<IconButton
iconProps={{ iconName: 'copy' }}
styles={{ root: { minWidth: '40px' } }}
onClick={() => { props.command && handleCopyCommand() }} />
</TooltipHost>
</Stack.Item>
</Stack>
{(!props.isLoading) ? renderCommand() :
<Spinner label="Generating command..." style={{ padding: "15px", backgroundColor: "#f2f2f2", border: '1px solid #e6e6e6' }} />}
</Stack>
);
}

Просмотреть файл

@ -0,0 +1,138 @@
import { MessageBar, MessageBarType, Pivot, PivotItem, PrimaryButton, Stack, TextField, TooltipHost } from "@fluentui/react";
import React, { useCallback, useEffect, useState } from "react";
import { HttpMethod, useAuthApiCall } from "../../../hooks/useAuthApiCall";
import { AirlockRequest, AirlockRequestStatus } from "../../../models/airlock";
import { ApiEndpoint } from "../../../models/apiEndpoints";
import { APIError } from "../../../models/exceptions";
import { ExceptionLayout } from "../ExceptionLayout";
import { CliCommand } from "../CliCommand";
interface AirlockRequestFilesSectionProps {
request: AirlockRequest;
workspaceApplicationIdURI: string;
}
export const AirlockRequestFilesSection: React.FunctionComponent<AirlockRequestFilesSectionProps> = (props: AirlockRequestFilesSectionProps) => {
const COPY_TOOL_TIP_DEFAULT_MESSAGE = "Copy to clipboard"
const [copyToolTipMessage, setCopyToolTipMessage] = useState<string>(COPY_TOOL_TIP_DEFAULT_MESSAGE);
const [sasUrl, setSasUrl] = useState<string>();
const [sasUrlError, setSasUrlError] = useState(false);
const [apiSasUrlError, setApiSasUrlError] = useState({} as APIError);
const apiCall = useAuthApiCall();
const generateSasUrl = useCallback(async () => {
if (props.request && props.request.workspaceId) {
try {
const linkObject = await apiCall(
`${ApiEndpoint.Workspaces}/${props.request.workspaceId}/${ApiEndpoint.AirlockRequests}/${props.request.id}/${ApiEndpoint.AirlockLink}`,
HttpMethod.Get,
props.workspaceApplicationIdURI
);
setSasUrl(linkObject.containerUrl);
} catch (err: any) {
err.userMessage = 'Error retrieving storage link';
setApiSasUrlError(err);
setSasUrlError(true);
}
}
}, [apiCall, props.request, props.workspaceApplicationIdURI]);
const parseSasUrl = (sasUrl: string) => {
const match = sasUrl.match(/https:\/\/(.*?).blob.core.windows.net\/(.*)\?(.*)$/);
if (!match) {
return
}
return {
StorageAccountName: match[1],
containerName: match[2],
sasToken: match[3]
}
};
const handleCopySasUrl = () => {
if (!sasUrl) {
return;
}
navigator.clipboard.writeText(sasUrl);
setCopyToolTipMessage("Copied")
setTimeout(() => setCopyToolTipMessage(COPY_TOOL_TIP_DEFAULT_MESSAGE), 3000);
}
const getAzureCliCommand = (sasUrl: string) => {
let containerDetails = parseSasUrl(sasUrl)
if (!containerDetails) {
return '';
}
let cliCommand = "";
if (props.request.status === AirlockRequestStatus.Draft) {
cliCommand = `az storage blob upload --file <~/path/to/file> --name <filename.filetype> --account-name ${containerDetails.StorageAccountName} --type block --container-name ${containerDetails.containerName} --sas-token "${containerDetails.sasToken}"`
} else {
cliCommand = `az storage blob download-batch --destination <~/destination/path/for/file> --source ${containerDetails.containerName} --account-name ${containerDetails.StorageAccountName} --sas-token "${containerDetails.sasToken}"`
}
return cliCommand;
};
useEffect(() => {
generateSasUrl()
}, [generateSasUrl]);
return (
<Stack>
<Pivot aria-label="Storage options">
<PivotItem headerText="SAS URL">
<Stack>
<Stack.Item style={{ paddingTop: '10px', paddingBottom: '10px' }}>
{
props.request.status === AirlockRequestStatus.Draft
? <small>Use the storage container SAS URL to upload your request file.</small>
: <small>Use the storage container SAS URL to view the request file.</small>
}
<Stack horizontal styles={{ root: { alignItems: 'center', paddingTop: '7px' } }}>
<Stack.Item grow>
<TextField readOnly value={sasUrl} />
</Stack.Item>
<TooltipHost content={copyToolTipMessage}>
<PrimaryButton
iconProps={{ iconName: 'copy' }}
styles={{ root: { minWidth: '40px' } }}
onClick={() => { handleCopySasUrl() }}
/>
</TooltipHost>
</Stack>
</Stack.Item>
{
props.request.status === AirlockRequestStatus.Draft && <MessageBar messageBarType={MessageBarType.info}>
Please upload a single file. Only single-file imports (including zip files) are supported.
</MessageBar>
}
</Stack>
</PivotItem>
<PivotItem headerText="CLI">
<Stack>
<Stack.Item style={{ paddingTop: '10px', paddingBottom: '10px' }}>
<small>Use Azure command-line interface (Azure CLI) to interact with the storage container.</small>
<hr style={{ border: "1px solid #faf9f8", borderRadius: "1px" }} />
</Stack.Item>
<Stack.Item style={{ paddingTop: '10px' }}>
<CliCommand
command={sasUrl ? getAzureCliCommand(sasUrl) : ''}
title={props.request.status === AirlockRequestStatus.Draft ? "Upload a file to the storage container" : "Download the file from the storage container"}
isLoading={!sasUrl && !sasUrlError}
/>
</Stack.Item>
</Stack>
</PivotItem>
</Pivot>
{
sasUrlError && <ExceptionLayout e={apiSasUrlError} />
}
</Stack>
);
};

Просмотреть файл

@ -1,14 +1,15 @@
import { DefaultButton, Dialog, DialogFooter, DocumentCard, DocumentCardActivity, DocumentCardDetails, DocumentCardTitle, DocumentCardType, FontIcon, getTheme, IStackItemStyles, IStackStyles, IStackTokens, mergeStyles, MessageBar, MessageBarType, Modal, Panel, PanelType, Persona, PersonaSize, PrimaryButton, Spinner, SpinnerSize, Stack, TextField } from "@fluentui/react";
import { DefaultButton, Dialog, DialogFooter, DocumentCard, DocumentCardActivity, DocumentCardDetails, DocumentCardTitle, DocumentCardType, FontIcon, getTheme, IStackItemStyles, IStackStyles, IStackTokens, mergeStyles, MessageBar, MessageBarType, Modal, Panel, PanelType, Persona, PersonaSize, PrimaryButton, Spinner, SpinnerSize, Stack} from "@fluentui/react";
import moment from "moment";
import React, { useCallback, useContext, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { WorkspaceContext } from "../../../contexts/WorkspaceContext";
import { HttpMethod, useAuthApiCall } from "../../../hooks/useAuthApiCall";
import { AirlockFilesLinkInvalidStatus, AirlockRequest, AirlockRequestAction, AirlockRequestStatus, AirlockReviewDecision } from "../../../models/airlock";
import { AirlockFilesLinkValidStatus, AirlockRequest, AirlockRequestAction, AirlockRequestStatus, AirlockReviewDecision } from "../../../models/airlock";
import { ApiEndpoint } from "../../../models/apiEndpoints";
import { APIError } from "../../../models/exceptions";
import { destructiveButtonStyles } from "../../../styles";
import { ExceptionLayout } from "../ExceptionLayout";
import { AirlockRequestFilesSection } from "./AirlockRequestFilesSection";
import { AirlockReviewRequest } from "./AirlockReviewRequest";
interface AirlockViewRequestProps {
@ -19,14 +20,11 @@ interface AirlockViewRequestProps {
export const AirlockViewRequest: React.FunctionComponent<AirlockViewRequestProps> = (props: AirlockViewRequestProps) => {
const {requestId} = useParams();
const [request, setRequest] = useState<AirlockRequest>();
const [filesLink, setFilesLink] = useState<string>();
const [filesLinkError, setFilesLinkError] = useState(false);
const [hideSubmitDialog, setHideSubmitDialog] = useState(true);
const [reviewIsOpen, setReviewIsOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState(false);
const [hideCancelDialog, setHideCancelDialog] = useState(true);
const [apiFilesLinkError, setApiFilesLinkError] = useState({} as APIError);
const [apiError, setApiError] = useState({} as APIError);
const workspaceCtx = useContext(WorkspaceContext);
const apiCall = useAuthApiCall();
@ -53,24 +51,6 @@ export const AirlockViewRequest: React.FunctionComponent<AirlockViewRequestProps
console.log(req);
}, [apiCall, requestId, props.requests, workspaceCtx.workspace.id, workspaceCtx.workspaceApplicationIdURI]);
// Retrieve a link to view/edit the airlock files
const generateFilesLink = useCallback(async () => {
if (request && request.workspaceId) {
try {
const linkObject = await apiCall(
`${ApiEndpoint.Workspaces}/${request.workspaceId}/${ApiEndpoint.AirlockRequests}/${request.id}/${ApiEndpoint.AirlockLink}`,
HttpMethod.Get,
workspaceCtx.workspaceApplicationIdURI
);
setFilesLink(linkObject.containerUrl);
} catch (err: any) {
err.userMessage = 'Error retrieving storage link';
setApiFilesLinkError(err);
setFilesLinkError(true);
}
}
}, [apiCall, request, workspaceCtx.workspaceApplicationIdURI]);
const dismissPanel = useCallback(() => navigate('../'), [navigate]);
// Submit an airlock request
@ -125,7 +105,7 @@ export const AirlockViewRequest: React.FunctionComponent<AirlockViewRequestProps
{
request.status === AirlockRequestStatus.Draft && <div style={{marginTop: '10px', marginBottom: '10px'}}>
<MessageBar>
This request is currently in draft. Add a file to the request's storage container using the SAS URL and submit when ready.
This request is currently in draft. Add a file to the request's storage container and submit when ready.
</MessageBar>
</div>
}
@ -241,41 +221,13 @@ export const AirlockViewRequest: React.FunctionComponent<AirlockViewRequestProps
</Stack.Item>
</Stack>
{
!AirlockFilesLinkInvalidStatus.includes(request.status) && <>
AirlockFilesLinkValidStatus.includes(request.status) && <>
<Stack style={{marginTop: '20px'}} styles={underlineStackStyles}>
<Stack.Item styles={stackItemStyles}>
<b>Files</b>
</Stack.Item>
</Stack>
<Stack>
<Stack.Item style={{paddingTop: '10px', paddingBottom: '10px'}}>
{
request.status === AirlockRequestStatus.Draft
? <small>Generate a storage container SAS URL to upload your request file.</small>
: <small>Generate a storage container SAS URL to view the request file.</small>
}
<Stack horizontal styles={{root: {alignItems: 'center', paddingTop: '7px'}}}>
<Stack.Item grow>
<TextField readOnly value={filesLink} defaultValue="Click generate to create a link" />
</Stack.Item>
{
filesLink ? <PrimaryButton
iconProps={{iconName: 'copy'}}
styles={{root: {minWidth: '40px'}}}
onClick={() => {navigator.clipboard.writeText(filesLink)}}
/> : <PrimaryButton onClick={() => {setFilesLinkError(false); generateFilesLink()}}>Generate</PrimaryButton>
}
</Stack>
</Stack.Item>
{
request.status === AirlockRequestStatus.Draft && <MessageBar messageBarType={MessageBarType.info}>
Please upload a single file. Only single-file imports (including zip files) are supported.
</MessageBar>
}
{
filesLinkError && <ExceptionLayout e={apiFilesLinkError} />
}
</Stack>
<AirlockRequestFilesSection request={request} workspaceApplicationIdURI={workspaceCtx.workspaceApplicationIdURI}/>
</>
}
{

Просмотреть файл

@ -59,11 +59,9 @@ export enum AirlockRequestAction {
Review = 'review'
}
export const AirlockFilesLinkInvalidStatus = [
AirlockRequestStatus.Rejected,
AirlockRequestStatus.Blocked,
AirlockRequestStatus.Failed,
AirlockRequestStatus.InReview
export const AirlockFilesLinkValidStatus = [
AirlockRequestStatus.Draft,
AirlockRequestStatus.Approved,
]
export enum AirlockReviewDecision {

Просмотреть файл

@ -5434,9 +5434,9 @@ htmlparser2@^6.1.0:
entities "^2.0.0"
http-cache-semantics@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390"
integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==
version "4.1.1"
resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a"
integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==
http-deceiver@^1.2.7:
version "1.2.7"