Storage: Add basic file upload management (#50638)

pull/51489/head
Ryan McKinley 3 years ago committed by GitHub
parent 4a76436be2
commit 4a00c7ebde
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 11
      .betterer.results
  2. 1
      .github/CODEOWNERS
  3. 1
      pkg/api/api.go
  4. 11
      pkg/api/index.go
  5. 7
      pkg/services/store/config.go
  6. 7
      pkg/services/store/http.go
  7. 26
      pkg/services/store/service.go
  8. 2
      pkg/services/store/service_test.go
  9. 47
      pkg/services/store/tree.go
  10. 5
      pkg/services/store/types.go
  11. 14
      pkg/util/strings.go
  12. 62
      public/app/features/admin/ExportStartButton.tsx
  13. 82
      public/app/features/admin/ExportStatus.tsx
  14. 2
      public/app/features/admin/ServerStats.tsx
  15. 20
      public/app/features/storage/AddRootView.tsx
  16. 60
      public/app/features/storage/Breadcrumb.tsx
  17. 127
      public/app/features/storage/ExportView.tsx
  18. 161
      public/app/features/storage/FileView.tsx
  19. 87
      public/app/features/storage/FolderView.tsx
  20. 114
      public/app/features/storage/RootView.tsx
  21. 205
      public/app/features/storage/StoragePage.tsx
  22. 136
      public/app/features/storage/UploadView.tsx
  23. 66
      public/app/features/storage/helper.ts
  24. 18
      public/app/features/storage/types.ts
  25. 7
      public/app/routes/routes.tsx

@ -5809,6 +5809,17 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"]
],
"public/app/features/storage/StoragePage.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
],
"public/app/features/storage/helper.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Do not use any type assertions.", "3"],
[0, 0, 0, "Unexpected any. Specify a different type.", "4"]
],
"public/app/features/teams/TeamGroupSync.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]

@ -108,6 +108,7 @@ pkg/tsdb/testdatasource/sims/ @grafana/grafana-edge-squad
/public/app/features/comments/ @grafana/grafana-edge-squad
/public/app/features/dimensions/ @grafana/grafana-edge-squad
/public/app/features/geo/ @grafana/grafana-edge-squad
/public/app/features/storage/ @grafana/grafana-edge-squad
/public/app/features/live/ @grafana/grafana-edge-squad
/public/app/features/explore/ @grafana/observability-experience-squad
/public/app/features/plugins @grafana/plugins-platform-frontend

@ -75,6 +75,7 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/admin/orgs", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, orgsAccessEvaluator), hs.Index)
r.Get("/admin/orgs/edit/:id", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, orgsAccessEvaluator), hs.Index)
r.Get("/admin/stats", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionServerStatsRead)), hs.Index)
r.Get("/admin/storage/*", reqGrafanaAdmin, hs.Index)
r.Get("/admin/ldap", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPStatusRead)), hs.Index)
r.Get("/styleguide", reqSignedIn, hs.Index)

@ -294,6 +294,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
Url: hs.Cfg.AppSubURL + "/org/apikeys",
})
}
if enableServiceAccount(hs, c) {
configNodes = append(configNodes, &dtos.NavLink{
Text: "Service accounts",
@ -646,6 +647,16 @@ func (hs *HTTPServer) buildAdminNavLinks(c *models.ReqContext) []*dtos.NavLink {
})
}
if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionSettingsRead)) && hs.Features.IsEnabled(featuremgmt.FlagStorage) {
adminNavLinks = append(adminNavLinks, &dtos.NavLink{
Text: "Storage",
Id: "storage",
Description: "Manage file storage",
Icon: "cube",
Url: hs.Cfg.AppSubURL + "/admin/storage",
})
}
if hs.Cfg.LDAPEnabled && hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPStatusRead)) {
adminNavLinks = append(adminNavLinks, &dtos.NavLink{
Text: "LDAP", Id: "ldap", Url: hs.Cfg.AppSubURL + "/admin/ldap", Icon: "book",

@ -1,9 +1,10 @@
package store
type RootStorageConfig struct {
Type string `json:"type"`
Prefix string `json:"prefix"`
Name string `json:"name"`
Type string `json:"type"`
Prefix string `json:"prefix"`
Name string `json:"name"`
Description string `json:"description"`
// Depending on type, these will be configured
Disk *StorageLocalDiskConfig `json:"disk,omitempty"`

@ -2,12 +2,14 @@ package store
import (
"errors"
"fmt"
"io/ioutil"
"net/http"
"strings"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
)
@ -57,7 +59,8 @@ func (s *httpStorage) Upload(c *models.ReqContext) response.Response {
}
c.Req.Body = http.MaxBytesReader(c.Resp, c.Req.Body, MAX_UPLOAD_SIZE)
if err := c.Req.ParseMultipartForm(MAX_UPLOAD_SIZE); err != nil {
return response.Error(400, "Please limit file uploaded under 1MB", err)
msg := fmt.Sprintf("Please limit file uploaded under %s", util.ByteCountSI(MAX_UPLOAD_SIZE))
return response.Error(400, msg, err)
}
files := c.Req.MultipartForm.File["file"]
@ -92,7 +95,7 @@ func (s *httpStorage) Upload(c *models.ReqContext) response.Response {
return errFileTooBig
}
path := RootUpload + "/" + fileHeader.Filename
path := RootResources + "/" + fileHeader.Filename
mimeType := http.DetectContentType(data)

@ -26,9 +26,9 @@ var ErrValidationFailed = errors.New("request validation failed")
var ErrFileAlreadyExists = errors.New("file exists")
const RootPublicStatic = "public-static"
const RootUpload = "upload"
const RootResources = "resources"
const MAX_UPLOAD_SIZE = 1024 * 1024 // 1MB
const MAX_UPLOAD_SIZE = 3 * 1024 * 1024 // 3MB
type StorageService interface {
registry.BackgroundService
@ -60,21 +60,25 @@ func ProvideService(sql *sqlstore.SQLStore, features featuremgmt.FeatureToggles,
Path: cfg.StaticRootPath,
Roots: []string{
"/testdata/",
// "/img/icons/",
// "/img/bg/",
"/img/",
"/gazetteer/",
"/maps/",
},
}).setReadOnly(true).setBuiltin(true),
}).setReadOnly(true).setBuiltin(true).
setDescription("Access files from the static public files"),
}
initializeOrgStorages := func(orgId int64) []storageRuntime {
storages := make([]storageRuntime, 0)
if features.IsEnabled(featuremgmt.FlagStorageLocalUpload) {
config := &StorageSQLConfig{orgId: orgId}
storages = append(storages, newSQLStorage(RootUpload, "Local file upload", config, sql).setBuiltin(true))
storages = append(storages,
newSQLStorage(RootResources,
"Resources",
&StorageSQLConfig{orgId: orgId}, sql).
setBuiltin(true).
setDescription("Upload custom resource files"))
}
return storages
}
@ -133,16 +137,16 @@ type UploadRequest struct {
}
func (s *standardStorageService) Upload(ctx context.Context, user *models.SignedInUser, req *UploadRequest) error {
upload, _ := s.tree.getRoot(getOrgId(user), RootUpload)
upload, _ := s.tree.getRoot(getOrgId(user), RootResources)
if upload == nil {
return ErrUploadFeatureDisabled
}
if !strings.HasPrefix(req.Path, RootUpload+"/") {
if !strings.HasPrefix(req.Path, RootResources+"/") {
return ErrUnsupportedStorage
}
storagePath := strings.TrimPrefix(req.Path, RootUpload)
storagePath := strings.TrimPrefix(req.Path, RootResources)
validationResult := s.validateUploadRequest(ctx, user, req, storagePath)
if !validationResult.ok {
grafanaStorageLogger.Warn("file upload validation failed", "filetype", req.MimeType, "path", req.Path, "reason", validationResult.reason)
@ -178,7 +182,7 @@ func (s *standardStorageService) Upload(ctx context.Context, user *models.Signed
}
func (s *standardStorageService) Delete(ctx context.Context, user *models.SignedInUser, path string) error {
upload, _ := s.tree.getRoot(getOrgId(user), RootUpload)
upload, _ := s.tree.getRoot(getOrgId(user), RootResources)
if upload == nil {
return fmt.Errorf("upload feature is not enabled")
}

@ -63,7 +63,7 @@ func TestUpload(t *testing.T) {
request := UploadRequest{
EntityType: EntityTypeImage,
Contents: make([]byte, 0),
Path: "upload/myFile.jpg",
Path: "resources/myFile.jpg",
MimeType: "image/jpg",
}
err = s.Upload(context.Background(), dummyUser, &request)

@ -89,31 +89,52 @@ func (t *nestedTree) ListFolder(ctx context.Context, orgId int64, path string) (
if path == "" || path == "/" {
t.assureOrgIsInitialized(orgId)
idx := 0
count := len(t.rootsByOrgId[ac.GlobalOrgID])
if orgId != ac.GlobalOrgID {
count += len(t.rootsByOrgId[orgId])
}
title := data.NewFieldFromFieldType(data.FieldTypeString, count)
names := data.NewFieldFromFieldType(data.FieldTypeString, count)
title := data.NewFieldFromFieldType(data.FieldTypeString, count)
descr := data.NewFieldFromFieldType(data.FieldTypeString, count)
types := data.NewFieldFromFieldType(data.FieldTypeString, count)
readOnly := data.NewFieldFromFieldType(data.FieldTypeBool, count)
builtIn := data.NewFieldFromFieldType(data.FieldTypeBool, count)
mtype := data.NewFieldFromFieldType(data.FieldTypeString, count)
title.Name = "title"
names.Name = "name"
descr.Name = "description"
mtype.Name = "mediaType"
for i, f := range t.rootsByOrgId[ac.GlobalOrgID] {
names.Set(i, f.Meta().Config.Prefix)
title.Set(i, f.Meta().Config.Name)
mtype.Set(i, "directory")
types.Name = "storageType"
readOnly.Name = "readOnly"
builtIn.Name = "builtIn"
for _, f := range t.rootsByOrgId[ac.GlobalOrgID] {
meta := f.Meta()
names.Set(idx, meta.Config.Prefix)
title.Set(idx, meta.Config.Name)
descr.Set(idx, meta.Config.Description)
mtype.Set(idx, "directory")
types.Set(idx, meta.Config.Type)
readOnly.Set(idx, meta.ReadOnly)
builtIn.Set(idx, meta.Builtin)
idx++
}
if orgId != ac.GlobalOrgID {
for i, f := range t.rootsByOrgId[orgId] {
names.Set(i, f.Meta().Config.Prefix)
title.Set(i, f.Meta().Config.Name)
mtype.Set(i, "directory")
for _, f := range t.rootsByOrgId[orgId] {
meta := f.Meta()
names.Set(idx, meta.Config.Prefix)
title.Set(idx, meta.Config.Name)
descr.Set(idx, meta.Config.Description)
mtype.Set(idx, "directory")
types.Set(idx, meta.Config.Type)
readOnly.Set(idx, meta.ReadOnly)
builtIn.Set(idx, meta.Builtin)
idx++
}
}
frame := data.NewFrame("", names, title, mtype)
frame := data.NewFrame("", names, title, descr, mtype, types, readOnly, builtIn)
frame.SetMeta(&data.FrameMeta{
Type: data.FrameTypeDirectoryListing,
})
@ -125,7 +146,11 @@ func (t *nestedTree) ListFolder(ctx context.Context, orgId int64, path string) (
return nil, nil // not found (or not ready)
}
listResponse, err := root.List(ctx, path, nil, &filestorage.ListOptions{Recursive: false, WithFolders: true, WithFiles: true})
listResponse, err := root.List(ctx, path, nil, &filestorage.ListOptions{
Recursive: false,
WithFolders: true,
WithFiles: true,
})
if err != nil {
return nil, err

@ -82,6 +82,11 @@ func (t *baseStorageRuntime) setBuiltin(val bool) *baseStorageRuntime {
return t
}
func (t *baseStorageRuntime) setDescription(v string) *baseStorageRuntime {
t.meta.Config.Description = v
return t
}
type RootStorageMeta struct {
ReadOnly bool `json:"editable,omitempty"`
Builtin bool `json:"builtin,omitempty"`

@ -117,3 +117,17 @@ func Capitalize(s string) string {
r[0] = unicode.ToUpper(r[0])
return string(r)
}
func ByteCountSI(b int64) string {
const unit = 1000
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB",
float64(b)/float64(div), "kMGTPE"[exp])
}

@ -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;
`,
};
};

@ -10,7 +10,6 @@ import { contextSrv } from '../../core/services/context_srv';
import { Loader } from '../plugins/admin/components/Loader';
import { CrawlerStatus } from './CrawlerStatus';
import { ExportStatus } from './ExportStatus';
import { getServerStats, ServerStat } from './state/apis';
export const ServerStats = () => {
@ -99,7 +98,6 @@ export const ServerStats = () => {
)}
{config.featureToggles.dashboardPreviews && config.featureToggles.dashboardPreviewsAdmin && <CrawlerStatus />}
{config.featureToggles.export && <ExportStatus />}
</>
);
};

@ -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;
}

@ -306,6 +306,13 @@ export function getAppRoutes(): RouteDescriptor[] {
() => import(/* webpackChunkName: "AdminEditOrgPage" */ 'app/features/admin/AdminEditOrgPage')
),
},
{
path: '/admin/storage/:path*',
roles: () => ['Admin'],
component: SafeDynamicImport(
() => import(/* webpackChunkName: "StoragePage" */ 'app/features/storage/StoragePage')
),
},
{
path: '/admin/stats',
component: SafeDynamicImport(

Loading…
Cancel
Save