mirror of
https://github.com/grafana/grafana.git
synced 2025-02-16 18:34:52 -06:00
Storage: Optionally overwrite existing files (#52067)
Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
parent
2090534635
commit
2f942c57e8
@ -169,7 +169,9 @@ export function FileDropzone({ options, children, readAs, onLoad, fileListRender
|
|||||||
let errors: string[] = [];
|
let errors: string[] = [];
|
||||||
rejectedFiles.map((rejectedFile) => {
|
rejectedFiles.map((rejectedFile) => {
|
||||||
rejectedFile.errors.map((error) => {
|
rejectedFile.errors.map((error) => {
|
||||||
errors.push(error.message);
|
if (errors.indexOf(error.message) === -1) {
|
||||||
|
errors.push(error.message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -75,13 +75,14 @@ func (s *httpStorage) Upload(c *models.ReqContext) response.Response {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
folder, ok := c.Req.MultipartForm.Value["folder"]
|
folder, ok := getMultipartFormValue(c.Req, "folder")
|
||||||
if !ok || len(folder) != 1 {
|
if !ok || folder == "" {
|
||||||
return response.JSON(400, map[string]interface{}{
|
return response.JSON(400, map[string]interface{}{
|
||||||
"message": "please specify the upload folder",
|
"message": "please specify the upload folder",
|
||||||
"err": true,
|
"err": true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
overwriteExistingFile, _ := getMultipartFormValue(c.Req, "overwriteExistingFile")
|
||||||
|
|
||||||
fileHeader := files[0]
|
fileHeader := files[0]
|
||||||
if fileHeader.Size > MAX_UPLOAD_SIZE {
|
if fileHeader.Size > MAX_UPLOAD_SIZE {
|
||||||
@ -107,7 +108,7 @@ func (s *httpStorage) Upload(c *models.ReqContext) response.Response {
|
|||||||
return errFileTooBig
|
return errFileTooBig
|
||||||
}
|
}
|
||||||
|
|
||||||
path := folder[0] + "/" + fileHeader.Filename
|
path := folder + "/" + fileHeader.Filename
|
||||||
|
|
||||||
mimeType := http.DetectContentType(data)
|
mimeType := http.DetectContentType(data)
|
||||||
|
|
||||||
@ -116,7 +117,7 @@ func (s *httpStorage) Upload(c *models.ReqContext) response.Response {
|
|||||||
MimeType: mimeType,
|
MimeType: mimeType,
|
||||||
EntityType: EntityTypeImage,
|
EntityType: EntityTypeImage,
|
||||||
Path: path,
|
Path: path,
|
||||||
OverwriteExistingFile: true,
|
OverwriteExistingFile: overwriteExistingFile == "true",
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -131,6 +132,14 @@ func (s *httpStorage) Upload(c *models.ReqContext) response.Response {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getMultipartFormValue(req *http.Request, key string) (string, bool) {
|
||||||
|
v, ok := req.MultipartForm.Value[key]
|
||||||
|
if !ok || len(v) != 1 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return v[0], ok
|
||||||
|
}
|
||||||
|
|
||||||
func (s *httpStorage) Read(c *models.ReqContext) response.Response {
|
func (s *httpStorage) Read(c *models.ReqContext) response.Response {
|
||||||
// full path is api/storage/read/upload/example.jpg, but we only want the part after read
|
// full path is api/storage/read/upload/example.jpg, but we only want the part after read
|
||||||
scope, path := getPathAndScope(c)
|
scope, path := getPathAndScope(c)
|
||||||
|
@ -13,9 +13,10 @@ interface Props {
|
|||||||
path: string;
|
path: string;
|
||||||
onPathChange: (p: string, view?: StorageView) => void;
|
onPathChange: (p: string, view?: StorageView) => void;
|
||||||
view: StorageView;
|
view: StorageView;
|
||||||
|
fileNames: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FolderView({ listing, path, onPathChange, view }: Props) {
|
export function FolderView({ listing, path, onPathChange, view, fileNames }: Props) {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
switch (view) {
|
switch (view) {
|
||||||
@ -35,6 +36,7 @@ export function FolderView({ listing, path, onPathChange, view }: Props) {
|
|||||||
onPathChange(path); // back to data
|
onPathChange(path); // back to data
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
fileNames={fileNames}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -18,7 +18,7 @@ import { ExportView } from './ExportView';
|
|||||||
import { FileView } from './FileView';
|
import { FileView } from './FileView';
|
||||||
import { FolderView } from './FolderView';
|
import { FolderView } from './FolderView';
|
||||||
import { RootView } from './RootView';
|
import { RootView } from './RootView';
|
||||||
import { getGrafanaStorage } from './helper';
|
import { getGrafanaStorage, filenameAlreadyExists } from './helper';
|
||||||
import { StorageView } from './types';
|
import { StorageView } from './types';
|
||||||
|
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
@ -211,7 +211,7 @@ export default function StoragePage(props: Props) {
|
|||||||
))}
|
))}
|
||||||
</TabsBar>
|
</TabsBar>
|
||||||
{isFolder ? (
|
{isFolder ? (
|
||||||
<FolderView path={path} listing={frame} onPathChange={setPath} view={view} />
|
<FolderView path={path} listing={frame} onPathChange={setPath} view={view} fileNames={fileNames} />
|
||||||
) : (
|
) : (
|
||||||
<FileView path={path} listing={frame} onPathChange={setPath} view={view} />
|
<FileView path={path} listing={frame} onPathChange={setPath} view={view} />
|
||||||
)}
|
)}
|
||||||
@ -231,10 +231,8 @@ export default function StoragePage(props: Props) {
|
|||||||
}}
|
}}
|
||||||
validate={(folderName) => {
|
validate={(folderName) => {
|
||||||
const lowerCase = folderName.toLowerCase();
|
const lowerCase = folderName.toLowerCase();
|
||||||
const trimmedLowerCase = lowerCase.trim();
|
|
||||||
const existingTrimmedLowerCaseNames = fileNames.map((f) => f.trim().toLowerCase());
|
|
||||||
|
|
||||||
if (existingTrimmedLowerCaseNames.includes(trimmedLowerCase)) {
|
if (filenameAlreadyExists(folderName, fileNames)) {
|
||||||
return 'A file or a folder with the same name already exists';
|
return 'A file or a folder with the same name already exists';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,14 +3,15 @@ import React, { useState } from 'react';
|
|||||||
import SVG from 'react-inlinesvg';
|
import SVG from 'react-inlinesvg';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { Button, ButtonGroup, Field, FileDropzone, useStyles2 } from '@grafana/ui';
|
import { Alert, Button, ButtonGroup, Checkbox, Field, FileDropzone, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { getGrafanaStorage } from './helper';
|
import { filenameAlreadyExists, getGrafanaStorage } from './helper';
|
||||||
import { UploadReponse } from './types';
|
import { UploadReponse } from './types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
folder: string;
|
folder: string;
|
||||||
onUpload: (rsp: UploadReponse) => void;
|
onUpload: (rsp: UploadReponse) => void;
|
||||||
|
fileNames: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ErrorResponse {
|
interface ErrorResponse {
|
||||||
@ -27,12 +28,13 @@ const FileDropzoneCustomChildren = ({ secondaryText = 'Drag and drop here or bro
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UploadView = ({ folder, onUpload }: Props) => {
|
export const UploadView = ({ folder, onUpload, fileNames }: Props) => {
|
||||||
const [file, setFile] = useState<File | undefined>(undefined);
|
const [file, setFile] = useState<File | undefined>(undefined);
|
||||||
|
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const [error, setError] = useState<ErrorResponse>({ message: '' });
|
const [error, setError] = useState<ErrorResponse>({ message: '' });
|
||||||
|
const [overwriteExistingFile, setOverwriteExistingFile] = useState(false);
|
||||||
|
|
||||||
const Preview = () => {
|
const Preview = () => {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
@ -58,7 +60,7 @@ export const UploadView = ({ folder, onUpload }: Props) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rsp = await getGrafanaStorage().upload(folder, file);
|
const rsp = await getGrafanaStorage().upload(folder, file, overwriteExistingFile);
|
||||||
if (rsp.status !== 200) {
|
if (rsp.status !== 200) {
|
||||||
setError(rsp);
|
setError(rsp);
|
||||||
} else {
|
} else {
|
||||||
@ -66,6 +68,9 @@ export const UploadView = ({ folder, onUpload }: Props) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filenameExists = file ? filenameAlreadyExists(file.name, fileNames) : false;
|
||||||
|
const isUploadDisabled = !file || (filenameExists && !overwriteExistingFile);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<FileDropzone
|
<FileDropzone
|
||||||
@ -84,8 +89,20 @@ export const UploadView = ({ folder, onUpload }: Props) => {
|
|||||||
{error.message !== '' ? <p>{error.message}</p> : Boolean(file) ? <Preview /> : <FileDropzoneCustomChildren />}
|
{error.message !== '' ? <p>{error.message}</p> : Boolean(file) ? <Preview /> : <FileDropzoneCustomChildren />}
|
||||||
</FileDropzone>
|
</FileDropzone>
|
||||||
|
|
||||||
|
{file && filenameExists && (
|
||||||
|
<div className={styles.alert}>
|
||||||
|
<Alert title={`${file.name} already exists`} severity="error">
|
||||||
|
<Checkbox
|
||||||
|
value={overwriteExistingFile}
|
||||||
|
onChange={() => setOverwriteExistingFile(!overwriteExistingFile)}
|
||||||
|
label="Overwrite existing file"
|
||||||
|
/>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ButtonGroup>
|
<ButtonGroup>
|
||||||
<Button className={styles.button} variant={'primary'} disabled={!file} onClick={doUpload}>
|
<Button className={styles.button} variant={'primary'} disabled={isUploadDisabled} onClick={doUpload}>
|
||||||
Upload
|
Upload
|
||||||
</Button>
|
</Button>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
@ -133,4 +150,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
color: ${theme.colors.text.secondary};
|
color: ${theme.colors.text.secondary};
|
||||||
margin-bottom: ${theme.spacing(2)};
|
margin-bottom: ${theme.spacing(2)};
|
||||||
`,
|
`,
|
||||||
|
alert: css`
|
||||||
|
padding-top: 10px;
|
||||||
|
`,
|
||||||
});
|
});
|
||||||
|
@ -7,7 +7,7 @@ import { UploadReponse } from './types';
|
|||||||
export interface GrafanaStorage {
|
export interface GrafanaStorage {
|
||||||
get: <T = any>(path: string) => Promise<T>;
|
get: <T = any>(path: string) => Promise<T>;
|
||||||
list: (path: string) => Promise<DataFrame | undefined>;
|
list: (path: string) => Promise<DataFrame | undefined>;
|
||||||
upload: (folder: string, file: File) => Promise<UploadReponse>;
|
upload: (folder: string, file: File, overwriteExistingFile: boolean) => Promise<UploadReponse>;
|
||||||
createFolder: (path: string) => Promise<{ error?: string }>;
|
createFolder: (path: string) => Promise<{ error?: string }>;
|
||||||
delete: (path: { isFolder: boolean; path: string }) => Promise<{ error?: string }>;
|
delete: (path: { isFolder: boolean; path: string }) => Promise<{ error?: string }>;
|
||||||
}
|
}
|
||||||
@ -82,10 +82,11 @@ class SimpleStorage implements GrafanaStorage {
|
|||||||
return req.isFolder ? this.deleteFolder({ path: req.path, force: true }) : this.deleteFile({ path: req.path });
|
return req.isFolder ? this.deleteFolder({ path: req.path, force: true }) : this.deleteFile({ path: req.path });
|
||||||
}
|
}
|
||||||
|
|
||||||
async upload(folder: string, file: File): Promise<UploadReponse> {
|
async upload(folder: string, file: File, overwriteExistingFile: boolean): Promise<UploadReponse> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('folder', folder);
|
formData.append('folder', folder);
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
formData.append('overwriteExistingFile', String(overwriteExistingFile));
|
||||||
const res = await fetch('/api/storage/upload', {
|
const res = await fetch('/api/storage/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
@ -112,3 +113,11 @@ export function getGrafanaStorage() {
|
|||||||
}
|
}
|
||||||
return storage;
|
return storage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function filenameAlreadyExists(folderName: string, fileNames: string[]) {
|
||||||
|
const lowerCase = folderName.toLowerCase();
|
||||||
|
const trimmedLowerCase = lowerCase.trim();
|
||||||
|
const existingTrimmedLowerCaseNames = fileNames.map((f) => f.trim().toLowerCase());
|
||||||
|
|
||||||
|
return existingTrimmedLowerCaseNames.includes(trimmedLowerCase);
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user