mirror of https://github.com/grafana/grafana
Storage: Add basic file upload management (#50638)
parent
4a76436be2
commit
4a00c7ebde
@ -1,62 +0,0 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React, { useState } from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { getBackendSrv } from '@grafana/runtime'; |
||||
import { Button, CodeEditor, Modal, useTheme2 } from '@grafana/ui'; |
||||
|
||||
export const ExportStartButton = () => { |
||||
const styles = getStyles(useTheme2()); |
||||
const [open, setOpen] = useState(false); |
||||
const [body, setBody] = useState({ |
||||
format: 'git', |
||||
git: {}, |
||||
}); |
||||
const onDismiss = () => setOpen(false); |
||||
const doStart = () => { |
||||
getBackendSrv() |
||||
.post('/api/admin/export', body) |
||||
.then((v) => { |
||||
console.log('GOT', v); |
||||
onDismiss(); |
||||
}); |
||||
}; |
||||
|
||||
return ( |
||||
<> |
||||
<Modal title={'Export grafana instance'} isOpen={open} onDismiss={onDismiss}> |
||||
<div className={styles.wrap}> |
||||
<CodeEditor |
||||
height={200} |
||||
value={JSON.stringify(body, null, 2) ?? ''} |
||||
showLineNumbers={false} |
||||
readOnly={false} |
||||
language="json" |
||||
showMiniMap={false} |
||||
onBlur={(text: string) => { |
||||
setBody(JSON.parse(text)); // force JSON?
|
||||
}} |
||||
/> |
||||
</div> |
||||
<Modal.ButtonRow> |
||||
<Button onClick={doStart}>Start</Button> |
||||
<Button variant="secondary" onClick={onDismiss}> |
||||
Cancel |
||||
</Button> |
||||
</Modal.ButtonRow> |
||||
</Modal> |
||||
|
||||
<Button onClick={() => setOpen(true)} variant="primary"> |
||||
Export |
||||
</Button> |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
wrap: css` |
||||
border: 2px solid #111; |
||||
`,
|
||||
}; |
||||
}; |
@ -1,82 +0,0 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React, { useEffect, useState } from 'react'; |
||||
|
||||
import { GrafanaTheme2, isLiveChannelMessageEvent, isLiveChannelStatusEvent, LiveChannelScope } from '@grafana/data'; |
||||
import { getBackendSrv, getGrafanaLiveSrv } from '@grafana/runtime'; |
||||
import { Button, useTheme2 } from '@grafana/ui'; |
||||
|
||||
import { ExportStartButton } from './ExportStartButton'; |
||||
|
||||
interface ExportStatusMessage { |
||||
running: boolean; |
||||
target: string; |
||||
started: number; |
||||
finished: number; |
||||
update: number; |
||||
count: number; |
||||
current: number; |
||||
last: string; |
||||
status: string; |
||||
} |
||||
|
||||
export const ExportStatus = () => { |
||||
const styles = getStyles(useTheme2()); |
||||
const [status, setStatus] = useState<ExportStatusMessage>(); |
||||
|
||||
useEffect(() => { |
||||
const subscription = getGrafanaLiveSrv() |
||||
.getStream<ExportStatusMessage>({ |
||||
scope: LiveChannelScope.Grafana, |
||||
namespace: 'broadcast', |
||||
path: 'export', |
||||
}) |
||||
.subscribe({ |
||||
next: (evt) => { |
||||
if (isLiveChannelMessageEvent(evt)) { |
||||
setStatus(evt.message); |
||||
} else if (isLiveChannelStatusEvent(evt)) { |
||||
setStatus(evt.message); |
||||
} |
||||
}, |
||||
}); |
||||
return () => { |
||||
subscription.unsubscribe(); |
||||
}; |
||||
}, []); |
||||
|
||||
if (!status) { |
||||
return ( |
||||
<div className={styles.wrap}> |
||||
<ExportStartButton /> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<div className={styles.wrap}> |
||||
<pre>{JSON.stringify(status, null, 2)}</pre> |
||||
{Boolean(!status.running) && <ExportStartButton />} |
||||
{Boolean(status.running) && ( |
||||
<Button |
||||
variant="secondary" |
||||
onClick={() => { |
||||
getBackendSrv().post('/api/admin/export/stop'); |
||||
}} |
||||
> |
||||
Stop |
||||
</Button> |
||||
)} |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
wrap: css` |
||||
border: 4px solid red; |
||||
`,
|
||||
running: css` |
||||
border: 4px solid green; |
||||
`,
|
||||
}; |
||||
}; |
@ -0,0 +1,20 @@ |
||||
import React from 'react'; |
||||
|
||||
import { Button } from '@grafana/ui'; |
||||
|
||||
import { StorageView } from './types'; |
||||
|
||||
interface Props { |
||||
onPathChange: (p: string, v?: StorageView) => void; |
||||
} |
||||
|
||||
export function AddRootView({ onPathChange }: Props) { |
||||
return ( |
||||
<div> |
||||
<div>TODO... Add ROOT</div> |
||||
<Button variant="secondary" onClick={() => onPathChange('/')}> |
||||
Cancel |
||||
</Button> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,60 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { uniqueId } from 'lodash'; |
||||
import React from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { Icon, IconName, useStyles2 } from '@grafana/ui'; |
||||
|
||||
interface Props { |
||||
rootIcon?: IconName; |
||||
pathName: string; |
||||
onPathChange: (path: string) => void; |
||||
} |
||||
|
||||
export function Breadcrumb({ pathName, onPathChange, rootIcon }: Props) { |
||||
const styles = useStyles2(getStyles); |
||||
const paths = pathName.split('/').filter(Boolean); |
||||
|
||||
return ( |
||||
<ul className={styles.breadCrumb}> |
||||
{rootIcon && ( |
||||
<li onClick={() => onPathChange('')}> |
||||
<Icon name={rootIcon} /> |
||||
</li> |
||||
)} |
||||
{paths.map((path, index) => { |
||||
let url = '/' + paths.slice(0, index + 1).join('/'); |
||||
const onClickBreadcrumb = () => onPathChange(url); |
||||
const isLastBreadcrumb = index === paths.length - 1; |
||||
return ( |
||||
<li key={uniqueId(path)} onClick={isLastBreadcrumb ? undefined : onClickBreadcrumb}> |
||||
{path} |
||||
</li> |
||||
); |
||||
})} |
||||
</ul> |
||||
); |
||||
} |
||||
|
||||
function getStyles(theme: GrafanaTheme2) { |
||||
return { |
||||
breadCrumb: css` |
||||
list-style: none; |
||||
padding: ${theme.spacing(2, 1)}; |
||||
|
||||
li { |
||||
display: inline; |
||||
|
||||
:not(:last-child) { |
||||
color: ${theme.colors.text.link}; |
||||
cursor: pointer; |
||||
} |
||||
+ li:before { |
||||
content: '>'; |
||||
padding: ${theme.spacing(1)}; |
||||
color: ${theme.colors.text.secondary}; |
||||
} |
||||
} |
||||
`,
|
||||
}; |
||||
} |
@ -0,0 +1,127 @@ |
||||
import React, { useEffect, useState } from 'react'; |
||||
|
||||
import { isLiveChannelMessageEvent, isLiveChannelStatusEvent, LiveChannelScope } from '@grafana/data'; |
||||
import { getBackendSrv, getGrafanaLiveSrv } from '@grafana/runtime'; |
||||
import { Button, CodeEditor, Modal } from '@grafana/ui'; |
||||
|
||||
import { StorageView } from './types'; |
||||
|
||||
interface ExportStatusMessage { |
||||
running: boolean; |
||||
target: string; |
||||
started: number; |
||||
finished: number; |
||||
update: number; |
||||
count: number; |
||||
current: number; |
||||
last: string; |
||||
status: string; |
||||
} |
||||
|
||||
interface Props { |
||||
onPathChange: (p: string, v?: StorageView) => void; |
||||
} |
||||
|
||||
export const ExportView = ({ onPathChange }: Props) => { |
||||
const [status, setStatus] = useState<ExportStatusMessage>(); |
||||
|
||||
const [open, setOpen] = useState(false); |
||||
const [body, setBody] = useState({ |
||||
format: 'git', |
||||
git: {}, |
||||
}); |
||||
const onDismiss = () => setOpen(false); |
||||
const doStart = () => { |
||||
getBackendSrv() |
||||
.post('/api/admin/export', body) |
||||
.then((v) => { |
||||
onDismiss(); |
||||
}); |
||||
}; |
||||
|
||||
useEffect(() => { |
||||
const subscription = getGrafanaLiveSrv() |
||||
.getStream<ExportStatusMessage>({ |
||||
scope: LiveChannelScope.Grafana, |
||||
namespace: 'broadcast', |
||||
path: 'export', |
||||
}) |
||||
.subscribe({ |
||||
next: (evt) => { |
||||
if (isLiveChannelMessageEvent(evt)) { |
||||
setStatus(evt.message); |
||||
} else if (isLiveChannelStatusEvent(evt)) { |
||||
setStatus(evt.message); |
||||
} |
||||
}, |
||||
}); |
||||
|
||||
// if not running, open the thread
|
||||
setTimeout(() => { |
||||
if (!status) { |
||||
setOpen(true); |
||||
} |
||||
}, 500); |
||||
|
||||
return () => { |
||||
subscription.unsubscribe(); |
||||
}; |
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); |
||||
|
||||
const renderButton = () => { |
||||
return ( |
||||
<> |
||||
<Modal title={'Export grafana instance'} isOpen={open} onDismiss={onDismiss}> |
||||
<div> |
||||
<CodeEditor |
||||
height={200} |
||||
value={JSON.stringify(body, null, 2) ?? ''} |
||||
showLineNumbers={false} |
||||
readOnly={false} |
||||
language="json" |
||||
showMiniMap={false} |
||||
onBlur={(text: string) => { |
||||
setBody(JSON.parse(text)); // force JSON?
|
||||
}} |
||||
/> |
||||
</div> |
||||
<Modal.ButtonRow> |
||||
<Button onClick={doStart}>Start</Button> |
||||
<Button variant="secondary" onClick={onDismiss}> |
||||
Cancel |
||||
</Button> |
||||
</Modal.ButtonRow> |
||||
</Modal> |
||||
|
||||
<Button onClick={() => setOpen(true)} variant="primary"> |
||||
Export |
||||
</Button> |
||||
<Button variant="secondary" onClick={() => onPathChange('/')}> |
||||
Cancel |
||||
</Button> |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
if (!status) { |
||||
return <div>{renderButton()}</div>; |
||||
} |
||||
|
||||
return ( |
||||
<div> |
||||
<pre>{JSON.stringify(status, null, 2)}</pre> |
||||
{Boolean(!status.running) && renderButton()} |
||||
{Boolean(status.running) && ( |
||||
<Button |
||||
variant="secondary" |
||||
onClick={() => { |
||||
getBackendSrv().post('/api/admin/export/stop'); |
||||
}} |
||||
> |
||||
Stop |
||||
</Button> |
||||
)} |
||||
</div> |
||||
); |
||||
}; |
@ -0,0 +1,161 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { isString } from 'lodash'; |
||||
import React, { useMemo } from 'react'; |
||||
import SVG from 'react-inlinesvg'; |
||||
import { useAsync } from 'react-use'; |
||||
import AutoSizer from 'react-virtualized-auto-sizer'; |
||||
|
||||
import { DataFrame, GrafanaTheme2 } from '@grafana/data'; |
||||
import { CodeEditor, useStyles2 } from '@grafana/ui'; |
||||
|
||||
import { getGrafanaStorage } from './helper'; |
||||
import { StorageView } from './types'; |
||||
|
||||
interface FileDisplayInfo { |
||||
category?: 'svg' | 'image' | 'text'; |
||||
language?: string; // match code editor
|
||||
} |
||||
|
||||
interface Props { |
||||
listing: DataFrame; |
||||
path: string; |
||||
onPathChange: (p: string, view?: StorageView) => void; |
||||
view: StorageView; |
||||
} |
||||
|
||||
export function FileView({ listing, path, onPathChange, view }: Props) { |
||||
const styles = useStyles2(getStyles); |
||||
const info = useMemo(() => getFileDisplayInfo(path), [path]); |
||||
const body = useAsync(async () => { |
||||
if (info.category === 'text') { |
||||
const rsp = await getGrafanaStorage().get(path); |
||||
if (isString(rsp)) { |
||||
return rsp; |
||||
} |
||||
return JSON.stringify(rsp, null, 2); |
||||
} |
||||
return null; |
||||
}, [info, path]); |
||||
|
||||
switch (view) { |
||||
case StorageView.Config: |
||||
return <div>CONFIGURE?</div>; |
||||
case StorageView.Perms: |
||||
return <div>Permissions</div>; |
||||
case StorageView.History: |
||||
return <div>TODO... history</div>; |
||||
} |
||||
|
||||
let src = `api/storage/read/${path}`; |
||||
if (src.endsWith('/')) { |
||||
src = src.substring(0, src.length - 1); |
||||
} |
||||
|
||||
switch (info.category) { |
||||
case 'svg': |
||||
return ( |
||||
<div> |
||||
<SVG src={src} className={styles.icon} /> |
||||
</div> |
||||
); |
||||
case 'image': |
||||
return ( |
||||
<div> |
||||
<a target={'_self'} href={src}> |
||||
<img src={src} className={styles.img} /> |
||||
</a> |
||||
</div> |
||||
); |
||||
case 'text': |
||||
return ( |
||||
<div className={styles.tableWrapper}> |
||||
<AutoSizer> |
||||
{({ width, height }) => ( |
||||
<CodeEditor |
||||
width={width} |
||||
height={height} |
||||
value={body.value ?? ''} |
||||
showLineNumbers={false} |
||||
readOnly={true} |
||||
language={info.language ?? 'text'} |
||||
showMiniMap={false} |
||||
onBlur={(text: string) => { |
||||
console.log('CHANGED!', text); |
||||
}} |
||||
/> |
||||
)} |
||||
</AutoSizer> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<div> |
||||
FILE: <a href={src}>{path}</a> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function getFileDisplayInfo(path: string): FileDisplayInfo { |
||||
const idx = path.lastIndexOf('.'); |
||||
if (idx < 0) { |
||||
return {}; |
||||
} |
||||
const suffix = path.substring(idx + 1).toLowerCase(); |
||||
switch (suffix) { |
||||
case 'svg': |
||||
return { category: 'svg' }; |
||||
case 'jpg': |
||||
case 'jpeg': |
||||
case 'png': |
||||
case 'webp': |
||||
case 'gif': |
||||
return { category: 'image' }; |
||||
|
||||
case 'geojson': |
||||
case 'json': |
||||
return { category: 'text', language: 'json' }; |
||||
case 'text': |
||||
case 'go': |
||||
case 'md': |
||||
return { category: 'text' }; |
||||
} |
||||
return {}; |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
// TODO: remove `height: 90%`
|
||||
wrapper: css` |
||||
display: flex; |
||||
flex-direction: column; |
||||
height: 100%; |
||||
`,
|
||||
tableControlRowWrapper: css` |
||||
display: flex; |
||||
flex-direction: row; |
||||
align-items: center; |
||||
margin-bottom: ${theme.spacing(2)}; |
||||
`,
|
||||
// TODO: remove `height: 100%`
|
||||
tableWrapper: css` |
||||
border: 1px solid ${theme.colors.border.medium}; |
||||
height: 100%; |
||||
`,
|
||||
uploadSpot: css` |
||||
margin-left: ${theme.spacing(2)}; |
||||
`,
|
||||
border: css` |
||||
border: 1px solid ${theme.colors.border.medium}; |
||||
padding: ${theme.spacing(2)}; |
||||
`,
|
||||
img: css` |
||||
max-width: 100%; |
||||
// max-height: 147px;
|
||||
// fill: ${theme.colors.text.primary};
|
||||
`,
|
||||
icon: css` |
||||
// max-width: 100%;
|
||||
// max-height: 147px;
|
||||
// fill: ${theme.colors.text.primary};
|
||||
`,
|
||||
}); |
@ -0,0 +1,87 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React from 'react'; |
||||
import AutoSizer from 'react-virtualized-auto-sizer'; |
||||
|
||||
import { DataFrame, GrafanaTheme2 } from '@grafana/data'; |
||||
import { Table, useStyles2 } from '@grafana/ui'; |
||||
|
||||
import { UploadView } from './UploadView'; |
||||
import { StorageView } from './types'; |
||||
|
||||
interface Props { |
||||
listing: DataFrame; |
||||
path: string; |
||||
onPathChange: (p: string, view?: StorageView) => void; |
||||
view: StorageView; |
||||
} |
||||
|
||||
export function FolderView({ listing, path, onPathChange, view }: Props) { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
switch (view) { |
||||
case StorageView.Config: |
||||
return <div>CONFIGURE?</div>; |
||||
case StorageView.Perms: |
||||
return <div>Permissions</div>; |
||||
case StorageView.Upload: |
||||
return ( |
||||
<UploadView |
||||
folder={path} |
||||
onUpload={(rsp) => { |
||||
console.log('Uploaded: ' + path); |
||||
if (rsp.path) { |
||||
onPathChange(rsp.path); |
||||
} else { |
||||
onPathChange(path); // back to data
|
||||
} |
||||
}} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<div className={styles.tableWrapper}> |
||||
<AutoSizer> |
||||
{({ width, height }) => ( |
||||
<div style={{ width: `${width}px`, height: `${height}px` }}> |
||||
<Table |
||||
height={height} |
||||
width={width} |
||||
data={listing} |
||||
noHeader={false} |
||||
showTypeIcons={false} |
||||
resizable={false} |
||||
/> |
||||
</div> |
||||
)} |
||||
</AutoSizer> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
// TODO: remove `height: 90%`
|
||||
wrapper: css` |
||||
display: flex; |
||||
flex-direction: column; |
||||
height: 100%; |
||||
`,
|
||||
tableControlRowWrapper: css` |
||||
display: flex; |
||||
flex-direction: row; |
||||
align-items: center; |
||||
margin-bottom: ${theme.spacing(2)}; |
||||
`,
|
||||
// TODO: remove `height: 100%`
|
||||
tableWrapper: css` |
||||
border: 1px solid ${theme.colors.border.medium}; |
||||
height: 100%; |
||||
`,
|
||||
uploadSpot: css` |
||||
margin-left: ${theme.spacing(2)}; |
||||
`,
|
||||
border: css` |
||||
border: 1px solid ${theme.colors.border.medium}; |
||||
padding: ${theme.spacing(2)}; |
||||
`,
|
||||
}); |
@ -0,0 +1,114 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React, { useMemo, useState } from 'react'; |
||||
|
||||
import { DataFrame, DataFrameView, GrafanaTheme2 } from '@grafana/data'; |
||||
import { config } from '@grafana/runtime'; |
||||
import { Button, Card, FilterInput, Icon, IconName, TagList, useStyles2, VerticalGroup } from '@grafana/ui'; |
||||
|
||||
import { StorageView } from './types'; |
||||
|
||||
interface Props { |
||||
root: DataFrame; |
||||
onPathChange: (p: string, v?: StorageView) => void; |
||||
} |
||||
|
||||
interface RootFolder { |
||||
name: string; |
||||
title: string; |
||||
storageType: string; |
||||
description: string; |
||||
readOnly: boolean; |
||||
builtIn: boolean; |
||||
} |
||||
|
||||
export function RootView({ root, onPathChange }: Props) { |
||||
const styles = useStyles2(getStyles); |
||||
const [searchQuery, setSearchQuery] = useState<string>(''); |
||||
let base = location.pathname; |
||||
if (!base.endsWith('/')) { |
||||
base += '/'; |
||||
} |
||||
|
||||
const roots = useMemo(() => { |
||||
const view = new DataFrameView<RootFolder>(root); |
||||
const all = view.map((v) => ({ ...v })); |
||||
if (searchQuery?.length) { |
||||
const lower = searchQuery.toLowerCase(); |
||||
return all.filter((v) => { |
||||
const isMatch = v.name.toLowerCase().indexOf(lower) >= 0 || v.description.toLowerCase().indexOf(lower) >= 0; |
||||
if (isMatch) { |
||||
return true; |
||||
} |
||||
return false; |
||||
}); |
||||
} |
||||
return all; |
||||
}, [searchQuery, root]); |
||||
|
||||
return ( |
||||
<div> |
||||
<div className="page-action-bar"> |
||||
<div className="gf-form gf-form--grow"> |
||||
<FilterInput placeholder="Search Storage" value={searchQuery} onChange={setSearchQuery} /> |
||||
</div> |
||||
<Button className="pull-right" onClick={() => onPathChange('', StorageView.AddRoot)}> |
||||
Add Root |
||||
</Button> |
||||
{config.featureToggles.export && ( |
||||
<Button className="pull-right" onClick={() => onPathChange('', StorageView.Export)}> |
||||
Export |
||||
</Button> |
||||
)} |
||||
</div> |
||||
<VerticalGroup> |
||||
{roots.map((v) => ( |
||||
<Card key={v.name} href={`admin/storage/${v.name}/`}> |
||||
<Card.Heading>{v.title ?? v.name}</Card.Heading> |
||||
<Card.Meta className={styles.clickable}>{v.description}</Card.Meta> |
||||
<Card.Tags className={styles.clickable}> |
||||
<TagList tags={getTags(v)} /> |
||||
</Card.Tags> |
||||
<Card.Figure className={styles.clickable}> |
||||
<Icon name={getIconName(v.storageType)} size="xxxl" className={styles.secondaryTextColor} /> |
||||
</Card.Figure> |
||||
</Card> |
||||
))} |
||||
</VerticalGroup> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function getStyles(theme: GrafanaTheme2) { |
||||
return { |
||||
secondaryTextColor: css` |
||||
color: ${theme.colors.text.secondary}; |
||||
`,
|
||||
clickable: css` |
||||
pointer-events: none; |
||||
`,
|
||||
}; |
||||
} |
||||
|
||||
function getTags(v: RootFolder) { |
||||
const tags: string[] = []; |
||||
if (v.builtIn) { |
||||
tags.push('Builtin'); |
||||
} |
||||
if (v.readOnly) { |
||||
tags.push('Read only'); |
||||
} |
||||
return tags; |
||||
} |
||||
|
||||
export function getIconName(type: string): IconName { |
||||
switch (type) { |
||||
case 'git': |
||||
return 'code-branch'; |
||||
case 'disk': |
||||
return 'folder-open'; |
||||
case 'sql': |
||||
return 'database'; |
||||
default: |
||||
return 'folder-open'; |
||||
} |
||||
} |
@ -0,0 +1,205 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React, { useMemo } from 'react'; |
||||
import { useAsync } from 'react-use'; |
||||
|
||||
import { DataFrame, GrafanaTheme2, isDataFrame, ValueLinkConfig } from '@grafana/data'; |
||||
import { locationService } from '@grafana/runtime'; |
||||
import { useStyles2, IconName, Spinner, TabsBar, Tab, Button, HorizontalGroup } from '@grafana/ui'; |
||||
import Page from 'app/core/components/Page/Page'; |
||||
import { useNavModel } from 'app/core/hooks/useNavModel'; |
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; |
||||
|
||||
import { AddRootView } from './AddRootView'; |
||||
import { Breadcrumb } from './Breadcrumb'; |
||||
import { ExportView } from './ExportView'; |
||||
import { FileView } from './FileView'; |
||||
import { FolderView } from './FolderView'; |
||||
import { RootView } from './RootView'; |
||||
import { getGrafanaStorage } from './helper'; |
||||
import { StorageView } from './types'; |
||||
|
||||
interface RouteParams { |
||||
path: string; |
||||
} |
||||
|
||||
interface QueryParams { |
||||
view: StorageView; |
||||
} |
||||
|
||||
interface Props extends GrafanaRouteComponentProps<RouteParams, QueryParams> {} |
||||
|
||||
export default function StoragePage(props: Props) { |
||||
const styles = useStyles2(getStyles); |
||||
const navModel = useNavModel('storage'); |
||||
const path = props.match.params.path ?? ''; |
||||
const view = props.queryParams.view ?? StorageView.Data; |
||||
const setPath = (p: string, view?: StorageView) => { |
||||
let url = ('/admin/storage/' + p).replace('//', '/'); |
||||
if (view && view !== StorageView.Data) { |
||||
url += '?view=' + view; |
||||
} |
||||
locationService.push(url); |
||||
}; |
||||
|
||||
const listing = useAsync((): Promise<DataFrame | undefined> => { |
||||
return getGrafanaStorage() |
||||
.list(path) |
||||
.then((frame) => { |
||||
if (frame) { |
||||
const name = frame.fields[0]; |
||||
frame.fields[0] = { |
||||
...name, |
||||
getLinks: (cfg: ValueLinkConfig) => { |
||||
const n = name.values.get(cfg.valueRowIndex ?? 0); |
||||
const p = path + '/' + n; |
||||
return [ |
||||
{ |
||||
title: `Open ${n}`, |
||||
href: `/admin/storage/${p}`, |
||||
target: '_self', |
||||
origin: name, |
||||
onClick: () => { |
||||
setPath(p); |
||||
}, |
||||
}, |
||||
]; |
||||
}, |
||||
}; |
||||
} |
||||
return frame; |
||||
}); |
||||
}, [path]); |
||||
|
||||
const isFolder = useMemo(() => { |
||||
let isFolder = path?.indexOf('/') < 0; |
||||
if (listing.value) { |
||||
const length = listing.value.length; |
||||
if (length > 1) { |
||||
isFolder = true; |
||||
} |
||||
if (length === 1) { |
||||
const first = listing.value.fields[0].values.get(0) as string; |
||||
isFolder = !path.endsWith(first); |
||||
} |
||||
} |
||||
return isFolder; |
||||
}, [path, listing]); |
||||
|
||||
const renderView = () => { |
||||
const isRoot = !path?.length || path === '/'; |
||||
switch (view) { |
||||
case StorageView.Export: |
||||
if (!isRoot) { |
||||
setPath(''); |
||||
return <Spinner />; |
||||
} |
||||
return <ExportView onPathChange={setPath} />; |
||||
|
||||
case StorageView.AddRoot: |
||||
if (!isRoot) { |
||||
setPath(''); |
||||
return <Spinner />; |
||||
} |
||||
return <AddRootView onPathChange={setPath} />; |
||||
} |
||||
|
||||
const frame = listing.value; |
||||
if (!isDataFrame(frame)) { |
||||
return <></>; |
||||
} |
||||
|
||||
if (isRoot) { |
||||
return <RootView root={frame} onPathChange={setPath} />; |
||||
} |
||||
|
||||
const opts = [{ what: StorageView.Data, text: 'Data' }]; |
||||
|
||||
// Root folders have a config page
|
||||
if (path.indexOf('/') < 0) { |
||||
opts.push({ what: StorageView.Config, text: 'Configure' }); |
||||
} |
||||
|
||||
// Lets only apply permissions to folders (for now)
|
||||
if (isFolder) { |
||||
opts.push({ what: StorageView.Perms, text: 'Permissions' }); |
||||
} else { |
||||
// TODO: only if the file exists in a storage engine with
|
||||
opts.push({ what: StorageView.History, text: 'History' }); |
||||
} |
||||
|
||||
// Hardcode the uploadable folder :)
|
||||
if (isFolder && path.startsWith('resources')) { |
||||
opts.push({ |
||||
what: StorageView.Upload, |
||||
text: 'Upload', |
||||
}); |
||||
} |
||||
const canAddFolder = isFolder && path.startsWith('resources'); |
||||
const canDelete = !isFolder && path.startsWith('resources/'); |
||||
|
||||
return ( |
||||
<div className={styles.wrapper}> |
||||
<HorizontalGroup width="100%" justify="space-between" height={25}> |
||||
<Breadcrumb pathName={path} onPathChange={setPath} rootIcon={navModel.node.icon as IconName} /> |
||||
<div> |
||||
{canAddFolder && <Button onClick={() => alert('TODO: new folder modal')}>New Folder</Button>} |
||||
{canDelete && ( |
||||
<Button variant="destructive" onClick={() => alert('TODO: confirm delete modal')}> |
||||
Delete |
||||
</Button> |
||||
)} |
||||
</div> |
||||
</HorizontalGroup> |
||||
|
||||
<TabsBar> |
||||
{opts.map((opt) => ( |
||||
<Tab |
||||
key={opt.what} |
||||
label={opt.text} |
||||
active={opt.what === view} |
||||
onChangeTab={() => setPath(path, opt.what)} |
||||
/> |
||||
))} |
||||
</TabsBar> |
||||
{isFolder ? ( |
||||
<FolderView path={path} listing={frame} onPathChange={setPath} view={view} /> |
||||
) : ( |
||||
<FileView path={path} listing={frame} onPathChange={setPath} view={view} /> |
||||
)} |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
return ( |
||||
<Page navModel={navModel}> |
||||
<Page.Contents isLoading={listing.loading}>{renderView()}</Page.Contents> |
||||
</Page> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
// TODO: remove `height: 90%`
|
||||
wrapper: css` |
||||
display: flex; |
||||
flex-direction: column; |
||||
height: 100%; |
||||
`,
|
||||
tableControlRowWrapper: css` |
||||
display: flex; |
||||
flex-direction: row; |
||||
align-items: center; |
||||
margin-bottom: ${theme.spacing(2)}; |
||||
`,
|
||||
// TODO: remove `height: 100%`
|
||||
tableWrapper: css` |
||||
border: 1px solid ${theme.colors.border.medium}; |
||||
height: 100%; |
||||
`,
|
||||
uploadSpot: css` |
||||
margin-left: ${theme.spacing(2)}; |
||||
`,
|
||||
border: css` |
||||
border: 1px solid ${theme.colors.border.medium}; |
||||
padding: ${theme.spacing(2)}; |
||||
`,
|
||||
}); |
@ -0,0 +1,136 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React, { useState } from 'react'; |
||||
import SVG from 'react-inlinesvg'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { Button, ButtonGroup, Field, FileDropzone, useStyles2 } from '@grafana/ui'; |
||||
|
||||
import { getGrafanaStorage } from './helper'; |
||||
import { UploadReponse } from './types'; |
||||
|
||||
interface Props { |
||||
folder: string; |
||||
onUpload: (rsp: UploadReponse) => void; |
||||
} |
||||
|
||||
interface ErrorResponse { |
||||
message: string; |
||||
} |
||||
|
||||
const FileDropzoneCustomChildren = ({ secondaryText = 'Drag and drop here or browse' }) => { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
return ( |
||||
<div className={styles.iconWrapper}> |
||||
<small className={styles.small}>{secondaryText}</small> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
export const UploadView = ({ folder, onUpload }: Props) => { |
||||
const [file, setFile] = useState<File | undefined>(undefined); |
||||
|
||||
const styles = useStyles2(getStyles); |
||||
|
||||
const [error, setError] = useState<ErrorResponse>({ message: '' }); |
||||
|
||||
const Preview = () => { |
||||
if (!file) { |
||||
return <></>; |
||||
} |
||||
const isImage = file.type?.startsWith('image/'); |
||||
const isSvg = file.name?.endsWith('.svg'); |
||||
|
||||
const src = URL.createObjectURL(file); |
||||
return ( |
||||
<Field label="Preview"> |
||||
<div className={styles.iconPreview}> |
||||
{isSvg && <SVG src={src} className={styles.img} />} |
||||
{isImage && !isSvg && <img src={src} className={styles.img} />} |
||||
</div> |
||||
</Field> |
||||
); |
||||
}; |
||||
|
||||
const doUpload = async () => { |
||||
if (!file) { |
||||
setError({ message: 'please select a file' }); |
||||
return; |
||||
} |
||||
|
||||
const rsp = await getGrafanaStorage().upload(folder, file); |
||||
if (rsp.status !== 200) { |
||||
setError(rsp); |
||||
} else { |
||||
onUpload(rsp); |
||||
} |
||||
}; |
||||
|
||||
return ( |
||||
<div> |
||||
<FileDropzone |
||||
readAs="readAsBinaryString" |
||||
onFileRemove={() => { |
||||
setFile(undefined); |
||||
}} |
||||
options={{ |
||||
accept: { 'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp'] }, |
||||
multiple: false, |
||||
onDrop: (acceptedFiles: File[]) => { |
||||
setFile(acceptedFiles[0]); |
||||
}, |
||||
}} |
||||
> |
||||
{error.message !== '' ? <p>{error.message}</p> : Boolean(file) ? <Preview /> : <FileDropzoneCustomChildren />} |
||||
</FileDropzone> |
||||
|
||||
<ButtonGroup> |
||||
<Button className={styles.button} variant={'primary'} disabled={!file} onClick={doUpload}> |
||||
Upload |
||||
</Button> |
||||
</ButtonGroup> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
resourcePickerPopover: css` |
||||
border-radius: ${theme.shape.borderRadius()}; |
||||
box-shadow: ${theme.shadows.z3}; |
||||
background: ${theme.colors.background.primary}; |
||||
border: 1px solid ${theme.colors.border.medium}; |
||||
`,
|
||||
resourcePickerPopoverContent: css` |
||||
width: 315px; |
||||
font-size: ${theme.typography.bodySmall.fontSize}; |
||||
min-height: 184px; |
||||
padding: ${theme.spacing(1)}; |
||||
display: flex; |
||||
flex-direction: column; |
||||
`,
|
||||
button: css` |
||||
margin: 12px 20px 5px; |
||||
`,
|
||||
iconPreview: css` |
||||
width: 238px; |
||||
height: 198px; |
||||
border: 1px solid ${theme.colors.border.medium}; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
`,
|
||||
img: css` |
||||
width: 147px; |
||||
height: 147px; |
||||
fill: ${theme.colors.text.primary}; |
||||
`,
|
||||
iconWrapper: css` |
||||
display: flex; |
||||
flex-direction: column; |
||||
align-items: center; |
||||
`,
|
||||
small: css` |
||||
color: ${theme.colors.text.secondary}; |
||||
margin-bottom: ${theme.spacing(2)}; |
||||
`,
|
||||
}); |
@ -0,0 +1,66 @@ |
||||
import { DataFrame, dataFrameFromJSON, DataFrameJSON, getDisplayProcessor } from '@grafana/data'; |
||||
import { config, getBackendSrv } from '@grafana/runtime'; |
||||
|
||||
import { UploadReponse } from './types'; |
||||
|
||||
// Likely should be built into the search interface!
|
||||
export interface GrafanaStorage { |
||||
get: <T = any>(path: string) => Promise<T>; |
||||
list: (path: string) => Promise<DataFrame | undefined>; |
||||
upload: (folder: string, file: File) => Promise<UploadReponse>; |
||||
} |
||||
|
||||
class SimpleStorage implements GrafanaStorage { |
||||
constructor() {} |
||||
|
||||
async get<T = any>(path: string): Promise<T> { |
||||
const storagePath = `api/storage/read/${path}`.replace('//', '/'); |
||||
return getBackendSrv().get<T>(storagePath); |
||||
} |
||||
|
||||
async list(path: string): Promise<DataFrame | undefined> { |
||||
let url = 'api/storage/list/'; |
||||
if (path) { |
||||
url += path + '/'; |
||||
} |
||||
const rsp = await getBackendSrv().get<DataFrameJSON>(url); |
||||
if (rsp?.data) { |
||||
const f = dataFrameFromJSON(rsp); |
||||
for (const field of f.fields) { |
||||
field.display = getDisplayProcessor({ field, theme: config.theme2 }); |
||||
} |
||||
return f; |
||||
} |
||||
return undefined; |
||||
} |
||||
|
||||
async upload(folder: string, file: File): Promise<UploadReponse> { |
||||
const formData = new FormData(); |
||||
formData.append('folder', folder); |
||||
formData.append('file', file); |
||||
const res = await fetch('/api/storage/upload', { |
||||
method: 'POST', |
||||
body: formData, |
||||
}); |
||||
|
||||
let body = (await res.json()) as UploadReponse; |
||||
if (!body) { |
||||
body = {} as any; |
||||
} |
||||
body.status = res.status; |
||||
body.statusText = res.statusText; |
||||
if (res.status !== 200 && !body.err) { |
||||
body.err = true; |
||||
} |
||||
return body; |
||||
} |
||||
} |
||||
|
||||
let storage: GrafanaStorage | undefined; |
||||
|
||||
export function getGrafanaStorage() { |
||||
if (!storage) { |
||||
storage = new SimpleStorage(); |
||||
} |
||||
return storage; |
||||
} |
@ -0,0 +1,18 @@ |
||||
export enum StorageView { |
||||
Data = 'data', |
||||
Config = 'config', |
||||
Perms = 'perms', |
||||
Upload = 'upload', |
||||
Export = 'export', |
||||
History = 'history', |
||||
AddRoot = 'add', |
||||
} |
||||
|
||||
export interface UploadReponse { |
||||
status: number; |
||||
statusText: string; |
||||
|
||||
err?: boolean; |
||||
message: string; |
||||
path: string; |
||||
} |
Loading…
Reference in new issue