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