mirror of
https://github.com/grafana/grafana.git
synced 2025-07-30 06:32:28 +08:00
Storage: Add basic file upload management (#50638)
This commit is contained in:
@ -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"]
|
||||
|
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@ -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",
|
||||
|
@ -4,6 +4,7 @@ type RootStorageConfig struct {
|
||||
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 />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
20
public/app/features/storage/AddRootView.tsx
Normal file
20
public/app/features/storage/AddRootView.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
60
public/app/features/storage/Breadcrumb.tsx
Normal file
60
public/app/features/storage/Breadcrumb.tsx
Normal file
@ -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};
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
}
|
127
public/app/features/storage/ExportView.tsx
Normal file
127
public/app/features/storage/ExportView.tsx
Normal file
@ -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>
|
||||
);
|
||||
};
|
161
public/app/features/storage/FileView.tsx
Normal file
161
public/app/features/storage/FileView.tsx
Normal file
@ -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};
|
||||
`,
|
||||
});
|
87
public/app/features/storage/FolderView.tsx
Normal file
87
public/app/features/storage/FolderView.tsx
Normal file
@ -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)};
|
||||
`,
|
||||
});
|
114
public/app/features/storage/RootView.tsx
Normal file
114
public/app/features/storage/RootView.tsx
Normal file
@ -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';
|
||||
}
|
||||
}
|
205
public/app/features/storage/StoragePage.tsx
Normal file
205
public/app/features/storage/StoragePage.tsx
Normal file
@ -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)};
|
||||
`,
|
||||
});
|
136
public/app/features/storage/UploadView.tsx
Normal file
136
public/app/features/storage/UploadView.tsx
Normal file
@ -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)};
|
||||
`,
|
||||
});
|
66
public/app/features/storage/helper.ts
Normal file
66
public/app/features/storage/helper.ts
Normal file
@ -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;
|
||||
}
|
18
public/app/features/storage/types.ts
Normal file
18
public/app/features/storage/types.ts
Normal file
@ -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(
|
||||
|
Reference in New Issue
Block a user