mirror of
https://github.com/grafana/grafana.git
synced 2025-07-30 20:42:31 +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.", "1"],
|
||||||
[0, 0, 0, "Do not use any type assertions.", "2"]
|
[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": [
|
"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.", "0"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
[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/comments/ @grafana/grafana-edge-squad
|
||||||
/public/app/features/dimensions/ @grafana/grafana-edge-squad
|
/public/app/features/dimensions/ @grafana/grafana-edge-squad
|
||||||
/public/app/features/geo/ @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/live/ @grafana/grafana-edge-squad
|
||||||
/public/app/features/explore/ @grafana/observability-experience-squad
|
/public/app/features/explore/ @grafana/observability-experience-squad
|
||||||
/public/app/features/plugins @grafana/plugins-platform-frontend
|
/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", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, orgsAccessEvaluator), hs.Index)
|
||||||
r.Get("/admin/orgs/edit/:id", 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/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("/admin/ldap", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPStatusRead)), hs.Index)
|
||||||
r.Get("/styleguide", reqSignedIn, 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",
|
Url: hs.Cfg.AppSubURL + "/org/apikeys",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if enableServiceAccount(hs, c) {
|
if enableServiceAccount(hs, c) {
|
||||||
configNodes = append(configNodes, &dtos.NavLink{
|
configNodes = append(configNodes, &dtos.NavLink{
|
||||||
Text: "Service accounts",
|
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)) {
|
if hs.Cfg.LDAPEnabled && hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPStatusRead)) {
|
||||||
adminNavLinks = append(adminNavLinks, &dtos.NavLink{
|
adminNavLinks = append(adminNavLinks, &dtos.NavLink{
|
||||||
Text: "LDAP", Id: "ldap", Url: hs.Cfg.AppSubURL + "/admin/ldap", Icon: "book",
|
Text: "LDAP", Id: "ldap", Url: hs.Cfg.AppSubURL + "/admin/ldap", Icon: "book",
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
type RootStorageConfig struct {
|
type RootStorageConfig struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Prefix string `json:"prefix"`
|
Prefix string `json:"prefix"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
|
||||||
// Depending on type, these will be configured
|
// Depending on type, these will be configured
|
||||||
Disk *StorageLocalDiskConfig `json:"disk,omitempty"`
|
Disk *StorageLocalDiskConfig `json:"disk,omitempty"`
|
||||||
|
@ -2,12 +2,14 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/api/response"
|
"github.com/grafana/grafana/pkg/api/response"
|
||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
|
"github.com/grafana/grafana/pkg/util"
|
||||||
"github.com/grafana/grafana/pkg/web"
|
"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)
|
c.Req.Body = http.MaxBytesReader(c.Resp, c.Req.Body, MAX_UPLOAD_SIZE)
|
||||||
if err := c.Req.ParseMultipartForm(MAX_UPLOAD_SIZE); err != nil {
|
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"]
|
files := c.Req.MultipartForm.File["file"]
|
||||||
@ -92,7 +95,7 @@ func (s *httpStorage) Upload(c *models.ReqContext) response.Response {
|
|||||||
return errFileTooBig
|
return errFileTooBig
|
||||||
}
|
}
|
||||||
|
|
||||||
path := RootUpload + "/" + fileHeader.Filename
|
path := RootResources + "/" + fileHeader.Filename
|
||||||
|
|
||||||
mimeType := http.DetectContentType(data)
|
mimeType := http.DetectContentType(data)
|
||||||
|
|
||||||
|
@ -26,9 +26,9 @@ var ErrValidationFailed = errors.New("request validation failed")
|
|||||||
var ErrFileAlreadyExists = errors.New("file exists")
|
var ErrFileAlreadyExists = errors.New("file exists")
|
||||||
|
|
||||||
const RootPublicStatic = "public-static"
|
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 {
|
type StorageService interface {
|
||||||
registry.BackgroundService
|
registry.BackgroundService
|
||||||
@ -60,21 +60,25 @@ func ProvideService(sql *sqlstore.SQLStore, features featuremgmt.FeatureToggles,
|
|||||||
Path: cfg.StaticRootPath,
|
Path: cfg.StaticRootPath,
|
||||||
Roots: []string{
|
Roots: []string{
|
||||||
"/testdata/",
|
"/testdata/",
|
||||||
// "/img/icons/",
|
|
||||||
// "/img/bg/",
|
|
||||||
"/img/",
|
"/img/",
|
||||||
"/gazetteer/",
|
"/gazetteer/",
|
||||||
"/maps/",
|
"/maps/",
|
||||||
},
|
},
|
||||||
}).setReadOnly(true).setBuiltin(true),
|
}).setReadOnly(true).setBuiltin(true).
|
||||||
|
setDescription("Access files from the static public files"),
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeOrgStorages := func(orgId int64) []storageRuntime {
|
initializeOrgStorages := func(orgId int64) []storageRuntime {
|
||||||
storages := make([]storageRuntime, 0)
|
storages := make([]storageRuntime, 0)
|
||||||
if features.IsEnabled(featuremgmt.FlagStorageLocalUpload) {
|
if features.IsEnabled(featuremgmt.FlagStorageLocalUpload) {
|
||||||
config := &StorageSQLConfig{orgId: orgId}
|
storages = append(storages,
|
||||||
storages = append(storages, newSQLStorage(RootUpload, "Local file upload", config, sql).setBuiltin(true))
|
newSQLStorage(RootResources,
|
||||||
|
"Resources",
|
||||||
|
&StorageSQLConfig{orgId: orgId}, sql).
|
||||||
|
setBuiltin(true).
|
||||||
|
setDescription("Upload custom resource files"))
|
||||||
}
|
}
|
||||||
|
|
||||||
return storages
|
return storages
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -133,16 +137,16 @@ type UploadRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *standardStorageService) Upload(ctx context.Context, user *models.SignedInUser, req *UploadRequest) error {
|
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 {
|
if upload == nil {
|
||||||
return ErrUploadFeatureDisabled
|
return ErrUploadFeatureDisabled
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(req.Path, RootUpload+"/") {
|
if !strings.HasPrefix(req.Path, RootResources+"/") {
|
||||||
return ErrUnsupportedStorage
|
return ErrUnsupportedStorage
|
||||||
}
|
}
|
||||||
|
|
||||||
storagePath := strings.TrimPrefix(req.Path, RootUpload)
|
storagePath := strings.TrimPrefix(req.Path, RootResources)
|
||||||
validationResult := s.validateUploadRequest(ctx, user, req, storagePath)
|
validationResult := s.validateUploadRequest(ctx, user, req, storagePath)
|
||||||
if !validationResult.ok {
|
if !validationResult.ok {
|
||||||
grafanaStorageLogger.Warn("file upload validation failed", "filetype", req.MimeType, "path", req.Path, "reason", validationResult.reason)
|
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 {
|
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 {
|
if upload == nil {
|
||||||
return fmt.Errorf("upload feature is not enabled")
|
return fmt.Errorf("upload feature is not enabled")
|
||||||
}
|
}
|
||||||
|
@ -63,7 +63,7 @@ func TestUpload(t *testing.T) {
|
|||||||
request := UploadRequest{
|
request := UploadRequest{
|
||||||
EntityType: EntityTypeImage,
|
EntityType: EntityTypeImage,
|
||||||
Contents: make([]byte, 0),
|
Contents: make([]byte, 0),
|
||||||
Path: "upload/myFile.jpg",
|
Path: "resources/myFile.jpg",
|
||||||
MimeType: "image/jpg",
|
MimeType: "image/jpg",
|
||||||
}
|
}
|
||||||
err = s.Upload(context.Background(), dummyUser, &request)
|
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 == "/" {
|
if path == "" || path == "/" {
|
||||||
t.assureOrgIsInitialized(orgId)
|
t.assureOrgIsInitialized(orgId)
|
||||||
|
|
||||||
|
idx := 0
|
||||||
count := len(t.rootsByOrgId[ac.GlobalOrgID])
|
count := len(t.rootsByOrgId[ac.GlobalOrgID])
|
||||||
if orgId != ac.GlobalOrgID {
|
if orgId != ac.GlobalOrgID {
|
||||||
count += len(t.rootsByOrgId[orgId])
|
count += len(t.rootsByOrgId[orgId])
|
||||||
}
|
}
|
||||||
|
|
||||||
title := data.NewFieldFromFieldType(data.FieldTypeString, count)
|
|
||||||
names := 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)
|
mtype := data.NewFieldFromFieldType(data.FieldTypeString, count)
|
||||||
title.Name = "title"
|
title.Name = "title"
|
||||||
names.Name = "name"
|
names.Name = "name"
|
||||||
|
descr.Name = "description"
|
||||||
mtype.Name = "mediaType"
|
mtype.Name = "mediaType"
|
||||||
for i, f := range t.rootsByOrgId[ac.GlobalOrgID] {
|
types.Name = "storageType"
|
||||||
names.Set(i, f.Meta().Config.Prefix)
|
readOnly.Name = "readOnly"
|
||||||
title.Set(i, f.Meta().Config.Name)
|
builtIn.Name = "builtIn"
|
||||||
mtype.Set(i, "directory")
|
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 {
|
if orgId != ac.GlobalOrgID {
|
||||||
for i, f := range t.rootsByOrgId[orgId] {
|
for _, f := range t.rootsByOrgId[orgId] {
|
||||||
names.Set(i, f.Meta().Config.Prefix)
|
meta := f.Meta()
|
||||||
title.Set(i, f.Meta().Config.Name)
|
names.Set(idx, meta.Config.Prefix)
|
||||||
mtype.Set(i, "directory")
|
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{
|
frame.SetMeta(&data.FrameMeta{
|
||||||
Type: data.FrameTypeDirectoryListing,
|
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)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -82,6 +82,11 @@ func (t *baseStorageRuntime) setBuiltin(val bool) *baseStorageRuntime {
|
|||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *baseStorageRuntime) setDescription(v string) *baseStorageRuntime {
|
||||||
|
t.meta.Config.Description = v
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
type RootStorageMeta struct {
|
type RootStorageMeta struct {
|
||||||
ReadOnly bool `json:"editable,omitempty"`
|
ReadOnly bool `json:"editable,omitempty"`
|
||||||
Builtin bool `json:"builtin,omitempty"`
|
Builtin bool `json:"builtin,omitempty"`
|
||||||
|
@ -117,3 +117,17 @@ func Capitalize(s string) string {
|
|||||||
r[0] = unicode.ToUpper(r[0])
|
r[0] = unicode.ToUpper(r[0])
|
||||||
return string(r)
|
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 { Loader } from '../plugins/admin/components/Loader';
|
||||||
|
|
||||||
import { CrawlerStatus } from './CrawlerStatus';
|
import { CrawlerStatus } from './CrawlerStatus';
|
||||||
import { ExportStatus } from './ExportStatus';
|
|
||||||
import { getServerStats, ServerStat } from './state/apis';
|
import { getServerStats, ServerStat } from './state/apis';
|
||||||
|
|
||||||
export const ServerStats = () => {
|
export const ServerStats = () => {
|
||||||
@ -99,7 +98,6 @@ export const ServerStats = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{config.featureToggles.dashboardPreviews && config.featureToggles.dashboardPreviewsAdmin && <CrawlerStatus />}
|
{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')
|
() => 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',
|
path: '/admin/stats',
|
||||||
component: SafeDynamicImport(
|
component: SafeDynamicImport(
|
||||||
|
Reference in New Issue
Block a user