зеркало из https://github.com/microsoft/AzureTRE.git
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:
Родитель
d3fae422e2
Коммит
15d9f27cb7
|
@ -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"
|
||||
|
|
Загрузка…
Ссылка в новой задаче