diff --git a/.betterer.results b/.betterer.results index b8a91c250db..2de54599dc3 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"] diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4f1486946fd..ad9fff7905b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/pkg/api/api.go b/pkg/api/api.go index 3d0c0279717..27c20636f36 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -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) diff --git a/pkg/api/index.go b/pkg/api/index.go index aaca41afe05..5a9189151fd 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -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", diff --git a/pkg/services/store/config.go b/pkg/services/store/config.go index cc65c201b69..3864397674d 100644 --- a/pkg/services/store/config.go +++ b/pkg/services/store/config.go @@ -1,9 +1,10 @@ package store type RootStorageConfig struct { - Type string `json:"type"` - Prefix string `json:"prefix"` - Name string `json:"name"` + Type string `json:"type"` + Prefix string `json:"prefix"` + Name string `json:"name"` + Description string `json:"description"` // Depending on type, these will be configured Disk *StorageLocalDiskConfig `json:"disk,omitempty"` diff --git a/pkg/services/store/http.go b/pkg/services/store/http.go index 413d5aa6fed..bdbb634a161 100644 --- a/pkg/services/store/http.go +++ b/pkg/services/store/http.go @@ -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) diff --git a/pkg/services/store/service.go b/pkg/services/store/service.go index 8be2bddb2a8..3412616b5e1 100644 --- a/pkg/services/store/service.go +++ b/pkg/services/store/service.go @@ -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") } diff --git a/pkg/services/store/service_test.go b/pkg/services/store/service_test.go index 073f135cb3b..026ed76669b 100644 --- a/pkg/services/store/service_test.go +++ b/pkg/services/store/service_test.go @@ -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) diff --git a/pkg/services/store/tree.go b/pkg/services/store/tree.go index 47ebf5ab9e5..89469b198b6 100644 --- a/pkg/services/store/tree.go +++ b/pkg/services/store/tree.go @@ -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 diff --git a/pkg/services/store/types.go b/pkg/services/store/types.go index 6acd3ad7e65..29151efae31 100644 --- a/pkg/services/store/types.go +++ b/pkg/services/store/types.go @@ -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"` diff --git a/pkg/util/strings.go b/pkg/util/strings.go index e066ca5c2bf..1cacce669ef 100644 --- a/pkg/util/strings.go +++ b/pkg/util/strings.go @@ -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]) +} diff --git a/public/app/features/admin/ExportStartButton.tsx b/public/app/features/admin/ExportStartButton.tsx deleted file mode 100644 index f94d4f5b081..00000000000 --- a/public/app/features/admin/ExportStartButton.tsx +++ /dev/null @@ -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 ( - <> - -
- { - setBody(JSON.parse(text)); // force JSON? - }} - /> -
- - - - -
- - - - ); -}; - -const getStyles = (theme: GrafanaTheme2) => { - return { - wrap: css` - border: 2px solid #111; - `, - }; -}; diff --git a/public/app/features/admin/ExportStatus.tsx b/public/app/features/admin/ExportStatus.tsx deleted file mode 100644 index 6b638b8f358..00000000000 --- a/public/app/features/admin/ExportStatus.tsx +++ /dev/null @@ -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(); - - useEffect(() => { - const subscription = getGrafanaLiveSrv() - .getStream({ - 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 ( -
- -
- ); - } - - return ( -
-
{JSON.stringify(status, null, 2)}
- {Boolean(!status.running) && } - {Boolean(status.running) && ( - - )} -
- ); -}; - -const getStyles = (theme: GrafanaTheme2) => { - return { - wrap: css` - border: 4px solid red; - `, - running: css` - border: 4px solid green; - `, - }; -}; diff --git a/public/app/features/admin/ServerStats.tsx b/public/app/features/admin/ServerStats.tsx index 1b3cf7799d6..672d1ba2746 100644 --- a/public/app/features/admin/ServerStats.tsx +++ b/public/app/features/admin/ServerStats.tsx @@ -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 && } - {config.featureToggles.export && } ); }; diff --git a/public/app/features/storage/AddRootView.tsx b/public/app/features/storage/AddRootView.tsx new file mode 100644 index 00000000000..c79a82b5c01 --- /dev/null +++ b/public/app/features/storage/AddRootView.tsx @@ -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 ( +
+
TODO... Add ROOT
+ +
+ ); +} diff --git a/public/app/features/storage/Breadcrumb.tsx b/public/app/features/storage/Breadcrumb.tsx new file mode 100644 index 00000000000..f2a824a54e4 --- /dev/null +++ b/public/app/features/storage/Breadcrumb.tsx @@ -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 ( +
    + {rootIcon && ( +
  • onPathChange('')}> + +
  • + )} + {paths.map((path, index) => { + let url = '/' + paths.slice(0, index + 1).join('/'); + const onClickBreadcrumb = () => onPathChange(url); + const isLastBreadcrumb = index === paths.length - 1; + return ( +
  • + {path} +
  • + ); + })} +
+ ); +} + +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}; + } + } + `, + }; +} diff --git a/public/app/features/storage/ExportView.tsx b/public/app/features/storage/ExportView.tsx new file mode 100644 index 00000000000..ed983e1639c --- /dev/null +++ b/public/app/features/storage/ExportView.tsx @@ -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(); + + 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({ + 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 ( + <> + +
+ { + setBody(JSON.parse(text)); // force JSON? + }} + /> +
+ + + + +
+ + + + + ); + }; + + if (!status) { + return
{renderButton()}
; + } + + return ( +
+
{JSON.stringify(status, null, 2)}
+ {Boolean(!status.running) && renderButton()} + {Boolean(status.running) && ( + + )} +
+ ); +}; diff --git a/public/app/features/storage/FileView.tsx b/public/app/features/storage/FileView.tsx new file mode 100644 index 00000000000..9051b4acec2 --- /dev/null +++ b/public/app/features/storage/FileView.tsx @@ -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
CONFIGURE?
; + case StorageView.Perms: + return
Permissions
; + case StorageView.History: + return
TODO... history
; + } + + let src = `api/storage/read/${path}`; + if (src.endsWith('/')) { + src = src.substring(0, src.length - 1); + } + + switch (info.category) { + case 'svg': + return ( +
+ +
+ ); + case 'image': + return ( +
+ + + +
+ ); + case 'text': + return ( +
+ + {({ width, height }) => ( + { + console.log('CHANGED!', text); + }} + /> + )} + +
+ ); + } + + return ( +
+ FILE: {path} +
+ ); +} + +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}; + `, +}); diff --git a/public/app/features/storage/FolderView.tsx b/public/app/features/storage/FolderView.tsx new file mode 100644 index 00000000000..9a10b8beaf1 --- /dev/null +++ b/public/app/features/storage/FolderView.tsx @@ -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
CONFIGURE?
; + case StorageView.Perms: + return
Permissions
; + case StorageView.Upload: + return ( + { + console.log('Uploaded: ' + path); + if (rsp.path) { + onPathChange(rsp.path); + } else { + onPathChange(path); // back to data + } + }} + /> + ); + } + + return ( +
+ + {({ width, height }) => ( +
+ + + )} + + + ); +} + +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)}; + `, +}); diff --git a/public/app/features/storage/RootView.tsx b/public/app/features/storage/RootView.tsx new file mode 100644 index 00000000000..04b40ad5ceb --- /dev/null +++ b/public/app/features/storage/RootView.tsx @@ -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(''); + let base = location.pathname; + if (!base.endsWith('/')) { + base += '/'; + } + + const roots = useMemo(() => { + const view = new DataFrameView(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 ( +
+
+
+ +
+ + {config.featureToggles.export && ( + + )} +
+ + {roots.map((v) => ( + + {v.title ?? v.name} + {v.description} + + + + + + + + ))} + +
+ ); +} + +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'; + } +} diff --git a/public/app/features/storage/StoragePage.tsx b/public/app/features/storage/StoragePage.tsx new file mode 100644 index 00000000000..73211f386c7 --- /dev/null +++ b/public/app/features/storage/StoragePage.tsx @@ -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 {} + +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 => { + 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 ; + } + return ; + + case StorageView.AddRoot: + if (!isRoot) { + setPath(''); + return ; + } + return ; + } + + const frame = listing.value; + if (!isDataFrame(frame)) { + return <>; + } + + if (isRoot) { + return ; + } + + 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 ( +
+ + +
+ {canAddFolder && } + {canDelete && ( + + )} +
+
+ + + {opts.map((opt) => ( + setPath(path, opt.what)} + /> + ))} + + {isFolder ? ( + + ) : ( + + )} +
+ ); + }; + + return ( + + {renderView()} + + ); +} + +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)}; + `, +}); diff --git a/public/app/features/storage/UploadView.tsx b/public/app/features/storage/UploadView.tsx new file mode 100644 index 00000000000..370f5370666 --- /dev/null +++ b/public/app/features/storage/UploadView.tsx @@ -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 ( +
+ {secondaryText} +
+ ); +}; + +export const UploadView = ({ folder, onUpload }: Props) => { + const [file, setFile] = useState(undefined); + + const styles = useStyles2(getStyles); + + const [error, setError] = useState({ message: '' }); + + const Preview = () => { + if (!file) { + return <>; + } + const isImage = file.type?.startsWith('image/'); + const isSvg = file.name?.endsWith('.svg'); + + const src = URL.createObjectURL(file); + return ( + +
+ {isSvg && } + {isImage && !isSvg && } +
+
+ ); + }; + + 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 ( +
+ { + setFile(undefined); + }} + options={{ + accept: { 'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp'] }, + multiple: false, + onDrop: (acceptedFiles: File[]) => { + setFile(acceptedFiles[0]); + }, + }} + > + {error.message !== '' ?

{error.message}

: Boolean(file) ? : } +
+ + + + +
+ ); +}; + +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)}; + `, +}); diff --git a/public/app/features/storage/helper.ts b/public/app/features/storage/helper.ts new file mode 100644 index 00000000000..f93c15d841c --- /dev/null +++ b/public/app/features/storage/helper.ts @@ -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: (path: string) => Promise; + list: (path: string) => Promise; + upload: (folder: string, file: File) => Promise; +} + +class SimpleStorage implements GrafanaStorage { + constructor() {} + + async get(path: string): Promise { + const storagePath = `api/storage/read/${path}`.replace('//', '/'); + return getBackendSrv().get(storagePath); + } + + async list(path: string): Promise { + let url = 'api/storage/list/'; + if (path) { + url += path + '/'; + } + const rsp = await getBackendSrv().get(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 { + 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; +} diff --git a/public/app/features/storage/types.ts b/public/app/features/storage/types.ts new file mode 100644 index 00000000000..9a729dee3b9 --- /dev/null +++ b/public/app/features/storage/types.ts @@ -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; +} diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index c3a59ffeb04..848b5cf8e2f 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -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(