From 4233a62aeb292d0cca7894b933a8c8c6727f0a97 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 23 Dec 2021 09:43:53 -0800 Subject: [PATCH] DashboardPreviews: add dashboard previews behind feature flag (#43226) Co-authored-by: Ashley Harrison Co-authored-by: Artur Wierzbicki --- .github/CODEOWNERS | 3 + packages/grafana-data/src/types/config.ts | 1 + .../src/selectors/components.ts | 2 + packages/grafana-runtime/src/config.ts | 1 + .../src/components/Tags/TagList.tsx | 26 +- pkg/api/api.go | 5 + pkg/api/http_server.go | 5 +- pkg/server/wire.go | 2 + .../sqlstore/migrations/migrations.go | 7 +- pkg/services/thumbs/crawler_http.go | 72 +++++ pkg/services/thumbs/dummy.go | 32 +++ pkg/services/thumbs/models.go | 103 +++++++ pkg/services/thumbs/service.go | 268 ++++++++++++++++++ pkg/services/thumbs/utils.go | 14 + pkg/setting/setting.go | 5 + .../DashboardSettings/GeneralSettings.tsx | 4 + .../DashboardSettings/PreviewSettings.tsx | 84 ++++++ .../features/search/components/ActionRow.tsx | 20 +- .../search/components/DashboardSearch.tsx | 11 + .../search/components/ManageDashboards.tsx | 9 + .../features/search/components/SearchCard.tsx | 238 ++++++++++++++++ .../search/components/SearchCardExpanded.tsx | 153 ++++++++++ .../search/components/SearchResults.test.tsx | 26 +- .../search/components/SearchResults.tsx | 118 +++++--- .../components/SearchResultsFilter.test.tsx | 1 + .../search/components/SearchResultsFilter.tsx | 8 +- public/app/features/search/constants.ts | 1 + .../app/plugins/panel/dashlist/DashList.tsx | 17 +- public/app/plugins/panel/dashlist/models.cue | 1 + .../app/plugins/panel/dashlist/models.gen.ts | 7 + public/app/plugins/panel/dashlist/module.tsx | 17 +- public/app/plugins/panel/dashlist/styles.ts | 8 + 32 files changed, 1214 insertions(+), 55 deletions(-) create mode 100644 pkg/services/thumbs/crawler_http.go create mode 100644 pkg/services/thumbs/dummy.go create mode 100644 pkg/services/thumbs/models.go create mode 100644 pkg/services/thumbs/service.go create mode 100644 pkg/services/thumbs/utils.go create mode 100644 public/app/features/dashboard/components/DashboardSettings/PreviewSettings.tsx create mode 100644 public/app/features/search/components/SearchCard.tsx create mode 100644 public/app/features/search/components/SearchCardExpanded.tsx diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 253105fd77f..dcd27cb3715 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -64,6 +64,9 @@ go.sum @grafana/backend-platform /pkg/services/datasourceproxy @grafana/plugins-platform-backend /pkg/services/datasources @grafana/plugins-platform-backend +# Dashboard previews / crawler (behind feature flag) +/pkg/services/thumbs @grafana/grafana-edge-squad + # Backend code docs /contribute/style-guides/backend.md @grafana/backend-platform /contribute/architecture/backend @grafana/backend-platform diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index bfc4f26e53b..4f6260e1af8 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -52,6 +52,7 @@ export interface FeatureToggles { recordedQueries: boolean; newNavigation: boolean; fullRangeLogsVolume: boolean; + dashboardPreviews: boolean; } /** diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 8755da1c708..a0c5360ddda 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -274,9 +274,11 @@ export const Components = { */ items: 'Search items', itemsV2: 'data-testid Search items', + cards: 'data-testid Search cards', collapseFolder: (sectionId: string) => `data-testid Collapse folder ${sectionId}`, expandFolder: (sectionId: string) => `data-testid Expand folder ${sectionId}`, dashboardItem: (item: string) => `${Components.Search.dashboardItems} ${item}`, + dashboardCard: (item: string) => `data-testid Search card ${item}`, dashboardItems: 'data-testid Dashboard search item', }, DashboardLinks: { diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index bde25a8b709..de25fd25d12 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -69,6 +69,7 @@ export class GrafanaBootConfig implements GrafanaConfig { recordedQueries: false, newNavigation: false, fullRangeLogsVolume: false, + dashboardPreviews: false, }; licenseInfo: LicenseInfo = {} as LicenseInfo; rendererAvailable = false; diff --git a/packages/grafana-ui/src/components/Tags/TagList.tsx b/packages/grafana-ui/src/components/Tags/TagList.tsx index 2fd0069e149..320898790bd 100644 --- a/packages/grafana-ui/src/components/Tags/TagList.tsx +++ b/packages/grafana-ui/src/components/Tags/TagList.tsx @@ -1,39 +1,49 @@ import React, { FC, memo } from 'react'; import { css, cx } from '@emotion/css'; import { OnTagClick, Tag } from './Tag'; +import { useTheme2 } from '../../themes'; +import { GrafanaTheme2 } from '@grafana/data'; export interface Props { + displayMax?: number; tags: string[]; onClick?: OnTagClick; /** Custom styles for the wrapper component */ className?: string; } -export const TagList: FC = memo(({ tags, onClick, className }) => { - const styles = getStyles(); +export const TagList: FC = memo(({ displayMax, tags, onClick, className }) => { + const theme = useTheme2(); + const styles = getStyles(theme, Boolean(displayMax && displayMax > 0)); + const numTags = tags.length; + const tagsToDisplay = displayMax ? tags.slice(0, displayMax) : tags; return ( - {tags.map((tag) => ( - + {tagsToDisplay.map((tag) => ( + ))} + {displayMax && displayMax > 0 && numTags - 1 > 0 && + {numTags - 1}} ); }); TagList.displayName = 'TagList'; -const getStyles = () => { +const getStyles = (theme: GrafanaTheme2, isTruncated: boolean) => { return { wrapper: css` + align-items: ${isTruncated ? 'center' : 'unset'}; display: flex; flex: 1 1 auto; flex-wrap: wrap; - margin-bottom: -6px; + flex-shrink: ${isTruncated ? 0 : 1}; justify-content: flex-end; + gap: 6px; `, - tag: css` - margin: 0 0 6px 6px; + moreTagsLabel: css` + color: ${theme.colors.text.secondary}; + font-size: ${theme.typography.size.sm}; `, }; }; diff --git a/pkg/api/api.go b/pkg/api/api.go index e6a22a5d3d8..c4b5213bfe5 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -327,6 +327,11 @@ func (hs *HTTPServer) registerRoutes() { dashboardRoute.Get("/uid/:uid", routing.Wrap(hs.GetDashboard)) dashboardRoute.Delete("/uid/:uid", routing.Wrap(hs.DeleteDashboardByUID)) + if hs.ThumbService != nil { + dashboardRoute.Get("/uid/:uid/img/:size/:theme", hs.ThumbService.GetImage) + dashboardRoute.Post("/uid/:uid/img/:size/:theme", hs.ThumbService.SetImage) + } + dashboardRoute.Post("/calculate-diff", routing.Wrap(CalculateDashboardDiff)) dashboardRoute.Post("/trim", routing.Wrap(hs.TrimDashboard)) diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index ea0a039316d..7a86d7e6c7f 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -14,6 +14,7 @@ import ( "sync" "github.com/grafana/grafana/pkg/services/query" + "github.com/grafana/grafana/pkg/services/thumbs" "github.com/grafana/grafana/pkg/api/routing" httpstatic "github.com/grafana/grafana/pkg/api/static" @@ -94,6 +95,7 @@ type HTTPServer struct { ShortURLService shorturls.Service Live *live.GrafanaLive LivePushGateway *pushhttp.Gateway + ThumbService thumbs.Service ContextHandler *contexthandler.ContextHandler SQLStore *sqlstore.SQLStore AlertEngine *alerting.AlertEngine @@ -124,7 +126,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi pluginDashboardManager plugins.PluginDashboardManager, pluginStore plugins.Store, pluginClient plugins.Client, pluginErrorResolver plugins.ErrorResolver, settingsProvider setting.Provider, dataSourceCache datasources.CacheService, userTokenService models.UserTokenService, - cleanUpService *cleanup.CleanUpService, shortURLService shorturls.Service, + cleanUpService *cleanup.CleanUpService, shortURLService shorturls.Service, thumbService thumbs.Service, remoteCache *remotecache.RemoteCache, provisioningService provisioning.ProvisioningService, loginService login.Service, accessControl accesscontrol.AccessControl, dataSourceProxy *datasourceproxy.DataSourceProxyService, searchService *search.SearchService, @@ -161,6 +163,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi AuthTokenService: userTokenService, cleanUpService: cleanUpService, ShortURLService: shortURLService, + ThumbService: thumbService, RemoteCacheService: remoteCache, ProvisioningService: provisioningService, Login: loginService, diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 2768778a877..24de3b8561c 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -59,6 +59,7 @@ import ( serviceaccountsmanager "github.com/grafana/grafana/pkg/services/serviceaccounts/manager" "github.com/grafana/grafana/pkg/services/shorturls" "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/thumbs" "github.com/grafana/grafana/pkg/services/updatechecker" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/azuremonitor" @@ -91,6 +92,7 @@ var wireBasicSet = wire.NewSet( query.ProvideService, bus.ProvideBus, wire.Bind(new(bus.Bus), new(*bus.InProcBus)), + thumbs.ProvideService, rendering.ProvideService, wire.Bind(new(rendering.Service), new(*rendering.RenderingService)), routing.ProvideRegister, diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 3bbeb1c463e..cb1d9b1d269 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -56,9 +56,12 @@ func (*OSSMigrations) AddMigration(mg *Migrator) { ualert.AddTablesMigrations(mg) ualert.AddDashAlertMigration(mg) addLibraryElementsMigrations(mg) - if mg.Cfg != nil || mg.Cfg.IsLiveConfigEnabled() { - addLiveChannelMigrations(mg) + if mg.Cfg != nil { + if mg.Cfg.IsLiveConfigEnabled() { + addLiveChannelMigrations(mg) + } } + ualert.RerunDashAlertMigration(mg) addSecretsMigration(mg) addKVStoreMigrations(mg) diff --git a/pkg/services/thumbs/crawler_http.go b/pkg/services/thumbs/crawler_http.go new file mode 100644 index 00000000000..6f8f7525abd --- /dev/null +++ b/pkg/services/thumbs/crawler_http.go @@ -0,0 +1,72 @@ +package thumbs + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "os" +) + +type renderHttp struct { + crawlerURL string + config crawConfig +} + +func newRenderHttp(crawlerURL string, cfg crawConfig) dashRenderer { + return &renderHttp{ + crawlerURL: crawlerURL, + config: cfg, + } +} + +func (r *renderHttp) GetPreview(req *previewRequest) *previewResponse { + p := getFilePath(r.config.ScreenshotsFolder, req) + if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) { + return r.queueRender(p, req) + } + + return &previewResponse{ + Path: p, + Code: 200, + } +} + +func (r *renderHttp) CrawlerCmd(cfg *crawlCmd) (json.RawMessage, error) { + cmd := r.config + cmd.crawlCmd = *cfg + + jsonData, err := json.Marshal(cmd) + if err != nil { + return nil, err + } + request, err := http.NewRequest("POST", r.crawlerURL, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + request.Header.Set("Content-Type", "application/json; charset=UTF-8") + + client := &http.Client{} + response, error := client.Do(request) + if error != nil { + return nil, err + } + defer func() { + _ = response.Body.Close() + }() + + return ioutil.ReadAll(response.Body) +} + +func (r *renderHttp) queueRender(p string, req *previewRequest) *previewResponse { + go func() { + fmt.Printf("todo? queue") + }() + + return &previewResponse{ + Code: 202, + Path: p, + } +} diff --git a/pkg/services/thumbs/dummy.go b/pkg/services/thumbs/dummy.go new file mode 100644 index 00000000000..9b73951e38f --- /dev/null +++ b/pkg/services/thumbs/dummy.go @@ -0,0 +1,32 @@ +package thumbs + +import ( + "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/models" +) + +// When the feature flag is not enabled we just implement a dummy service +type dummyService struct{} + +func (ds *dummyService) GetImage(c *models.ReqContext) { + c.JSON(400, map[string]string{"error": "invalid size"}) +} + +func (ds *dummyService) SetImage(c *models.ReqContext) { + c.JSON(400, map[string]string{"error": "invalid size"}) +} + +func (ds *dummyService) Enabled() bool { + return false +} + +func (ds *dummyService) StartCrawler(c *models.ReqContext) response.Response { + result := make(map[string]string) + result["error"] = "Not enabled" + return response.JSON(200, result) +} +func (ds *dummyService) StopCrawler(c *models.ReqContext) response.Response { + result := make(map[string]string) + result["error"] = "Not enabled" + return response.JSON(200, result) +} diff --git a/pkg/services/thumbs/models.go b/pkg/services/thumbs/models.go new file mode 100644 index 00000000000..17c46836e52 --- /dev/null +++ b/pkg/services/thumbs/models.go @@ -0,0 +1,103 @@ +package thumbs + +import "encoding/json" + +type PreviewSize string + +const ( + // PreviewSizeThumb is a small 320x240 preview + PreviewSizeThumb PreviewSize = "thumb" + + // PreviewSizeLarge is a large image 2000x1500 + PreviewSizeLarge PreviewSize = "large" + + // PreviewSizeLarge is a large image 512x???? + PreviewSizeTall PreviewSize = "tall" +) + +// IsKnownSize checks if the value is a standard size +func (p PreviewSize) IsKnownSize() bool { + switch p { + case + PreviewSizeThumb, + PreviewSizeLarge, + PreviewSizeTall: + return true + } + return false +} + +func getPreviewSize(str string) (PreviewSize, bool) { + switch str { + case string(PreviewSizeThumb): + return PreviewSizeThumb, true + case string(PreviewSizeLarge): + return PreviewSizeLarge, true + case string(PreviewSizeTall): + return PreviewSizeTall, true + } + return PreviewSizeThumb, false +} + +func getTheme(str string) (string, bool) { + switch str { + case "light": + return str, true + case "dark": + return str, true + } + return "dark", false +} + +type previewRequest struct { + Kind string `json:"kind"` + OrgID int64 `json:"orgId"` + UID string `json:"uid"` + Size PreviewSize `json:"size"` + Theme string `json:"theme"` +} + +type previewResponse struct { + Code int `json:"code"` // 200 | 202 + Path string `json:"path"` // local file path to serve + URL string `json:"url"` // redirect to this URL +} + +// export enum CrawlerMode { +// Thumbs = 'thumbs', +// Analytics = 'analytics', // Enterprise only +// Migrate = 'migrate', +// } + +// export enum CrawlerAction { +// Run = 'run', +// Stop = 'stop', +// Queue = 'queue', // TODO (later!) move some to the front +// } + +type crawlCmd struct { + Mode string `json:"mode"` // thumbs | analytics | migrate + Action string `json:"action"` // run | stop | queue + Theme string `json:"theme"` // light | dark + User string `json:"user"` // :( + Password string `json:"password"` // :( + Concurrency int `json:"concurrency"` // number of pages to run in parallel + + Path string `json:"path"` // eventually for queue +} + +type crawConfig struct { + crawlCmd + + // Sent to the crawler with each command + URL string `json:"url"` + ScreenshotsFolder string `json:"screenshotsFolder"` +} + +type dashRenderer interface { + // Assumes you have already authenticated as admin + GetPreview(req *previewRequest) *previewResponse + + // Assumes you have already authenticated as admin + CrawlerCmd(cfg *crawlCmd) (json.RawMessage, error) +} diff --git a/pkg/services/thumbs/service.go b/pkg/services/thumbs/service.go new file mode 100644 index 00000000000..ed43a35400e --- /dev/null +++ b/pkg/services/thumbs/service.go @@ -0,0 +1,268 @@ +package thumbs + +import ( + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/grafana/grafana/pkg/infra/log" + + "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/guardian" + "github.com/grafana/grafana/pkg/services/rendering" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/web" + "github.com/segmentio/encoding/json" +) + +var ( + tlog log.Logger = log.New("thumbnails") +) + +type Service interface { + Enabled() bool + GetImage(c *models.ReqContext) + SetImage(c *models.ReqContext) + + // Must be admin + StartCrawler(c *models.ReqContext) response.Response + StopCrawler(c *models.ReqContext) response.Response +} + +func ProvideService(cfg *setting.Cfg, renderService rendering.Service) Service { + if !cfg.IsDashboardPreviesEnabled() { + return &dummyService{} + } + + root := filepath.Join(cfg.DataPath, "crawler", "preview") + url := strings.TrimSuffix(cfg.RendererUrl, "/render") + "/scan" + + renderer := newRenderHttp(url, crawConfig{ + URL: strings.TrimSuffix(cfg.RendererCallbackUrl, "/"), + ScreenshotsFolder: root, + }) + + tempdir := filepath.Join(cfg.DataPath, "temp") + _ = os.MkdirAll(tempdir, 0700) + + return &thumbService{ + renderer: renderer, + root: root, + tempdir: tempdir, + } +} + +type thumbService struct { + renderer dashRenderer + root string + tempdir string +} + +func (hs *thumbService) Enabled() bool { + return true +} + +func (hs *thumbService) parseImageReq(c *models.ReqContext, checkSave bool) *previewRequest { + params := web.Params(c.Req) + + size, ok := getPreviewSize(params[":size"]) + if !ok { + c.JSON(400, map[string]string{"error": "invalid size"}) + return nil + } + + theme, ok := getTheme(params[":theme"]) + if !ok { + c.JSON(400, map[string]string{"error": "invalid theme"}) + return nil + } + + req := &previewRequest{ + Kind: "dash", + OrgID: c.OrgId, + UID: params[":uid"], + Theme: theme, + Size: size, + } + + if len(req.UID) < 1 { + c.JSON(400, map[string]string{"error": "missing UID"}) + return nil + } + + // Check permissions and status + status := hs.getStatus(c, req.UID, checkSave) + if status != 200 { + c.JSON(status, map[string]string{"error": fmt.Sprintf("code: %d", status)}) + return nil + } + return req +} + +func (hs *thumbService) GetImage(c *models.ReqContext) { + req := hs.parseImageReq(c, false) + if req == nil { + return // already returned value + } + + rsp := hs.renderer.GetPreview(req) + if rsp.Code == 200 { + if rsp.Path != "" { + if strings.HasSuffix(rsp.Path, ".webp") { + c.Resp.Header().Set("Content-Type", "image/webp") + } else if strings.HasSuffix(rsp.Path, ".png") { + c.Resp.Header().Set("Content-Type", "image/png") + } + c.Resp.Header().Set("Content-Type", "image/png") + http.ServeFile(c.Resp, c.Req, rsp.Path) + return + } + if rsp.URL != "" { + // todo redirect + fmt.Printf("TODO redirect: %s\n", rsp.URL) + } + } + + if rsp.Code == 202 { + c.JSON(202, map[string]string{"path": rsp.Path, "todo": "queue processing"}) + return + } + + c.JSON(500, map[string]string{"path": rsp.Path, "error": "unknown!"}) +} + +func (hs *thumbService) SetImage(c *models.ReqContext) { + req := hs.parseImageReq(c, false) + if req == nil { + return // already returned value + } + + r := c.Req + + // Parse our multipart form, 10 << 20 specifies a maximum + // upload of 10 MB files. + err := r.ParseMultipartForm(10 << 20) + if err != nil { + c.JSON(400, map[string]string{"error": "invalid upload size"}) + return + } + + // FormFile returns the first file for the given key `myFile` + // it also returns the FileHeader so we can get the Filename, + // the Header and the size of the file + file, handler, err := r.FormFile("file") + if err != nil { + c.JSON(400, map[string]string{"error": "missing multi-part form field named 'file'"}) + fmt.Println("error", err) + return + } + defer func() { + _ = file.Close() + }() + tlog.Info("Uploaded File: %+v\n", handler.Filename) + tlog.Info("File Size: %+v\n", handler.Size) + tlog.Info("MIME Header: %+v\n", handler.Header) + + // Create a temporary file within our temp-images directory that follows + // a particular naming pattern + tempFile, err := ioutil.TempFile(hs.tempdir, "upload-*") + if err != nil { + c.JSON(400, map[string]string{"error": "error creating temp file"}) + fmt.Println("error", err) + tlog.Info("ERROR", "err", handler.Header) + return + } + defer func() { + _ = tempFile.Close() + }() + + // read all of the contents of our uploaded file into a + // byte array + fileBytes, err := ioutil.ReadAll(file) + if err != nil { + fmt.Println(err) + } + // write this byte array to our temporary file + _, err = tempFile.Write(fileBytes) + if err != nil { + c.JSON(400, map[string]string{"error": "error writing file"}) + fmt.Println("error", err) + + return + } + + p := getFilePath(hs.root, req) + err = os.Rename(tempFile.Name(), p) + if err != nil { + c.JSON(400, map[string]string{"error": "unable to rename file"}) + return + } + + c.JSON(200, map[string]int{"OK": len(fileBytes)}) +} + +func (hs *thumbService) StartCrawler(c *models.ReqContext) response.Response { + body, err := io.ReadAll(c.Req.Body) + if err != nil { + return response.Error(500, "error reading bytes", err) + } + cmd := &crawlCmd{} + err = json.Unmarshal(body, cmd) + if err != nil { + return response.Error(500, "error parsing bytes", err) + } + cmd.Action = "start" + + msg, err := hs.renderer.CrawlerCmd(cmd) + if err != nil { + return response.Error(500, "error starting", err) + } + + header := make(http.Header) + header.Set("Content-Type", "application/json") + return response.CreateNormalResponse(header, msg, 200) +} + +func (hs *thumbService) StopCrawler(c *models.ReqContext) response.Response { + _, err := hs.renderer.CrawlerCmd(&crawlCmd{ + Action: "stop", + }) + if err != nil { + return response.Error(500, "error stopping crawler", err) + } + + result := make(map[string]string) + result["message"] = "Stopping..." + return response.JSON(200, result) +} + +// Ideally this service would not require first looking up the full dashboard just to bet the id! +func (hs *thumbService) getStatus(c *models.ReqContext, uid string, checkSave bool) int { + query := models.GetDashboardQuery{Uid: uid, OrgId: c.OrgId} + + if err := bus.DispatchCtx(c.Req.Context(), &query); err != nil { + return 404 // not found + } + + dash := query.Result + + guardian := guardian.New(c.Req.Context(), dash.Id, c.OrgId, c.SignedInUser) + if checkSave { + if canSave, err := guardian.CanSave(); err != nil || !canSave { + return 403 // forbidden + } + return 200 + } + + if canView, err := guardian.CanView(); err != nil || !canView { + return 403 // forbidden + } + + return 200 // found and OK +} diff --git a/pkg/services/thumbs/utils.go b/pkg/services/thumbs/utils.go new file mode 100644 index 00000000000..0ad4cfca02e --- /dev/null +++ b/pkg/services/thumbs/utils.go @@ -0,0 +1,14 @@ +package thumbs + +import ( + "fmt" + "path/filepath" +) + +func getFilePath(root string, req *previewRequest) string { + ext := "webp" + if req.Size != PreviewSizeThumb { + ext = "png" + } + return filepath.Join(root, fmt.Sprintf("%s-%s-%s.%s", req.UID, req.Size, req.Theme, ext)) +} diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index b8256c7b25a..e698e70a294 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -433,6 +433,11 @@ func (cfg Cfg) IsLiveConfigEnabled() bool { return cfg.FeatureToggles["live-config"] } +// IsLiveConfigEnabled returns true if live should be able to save configs to SQL tables +func (cfg Cfg) IsDashboardPreviesEnabled() bool { + return cfg.FeatureToggles["dashboardPreviews"] +} + // IsTrimDefaultsEnabled returns whether the standalone trim dashboard default feature is enabled. func (cfg Cfg) IsTrimDefaultsEnabled() bool { return cfg.FeatureToggles["trimDefaults"] diff --git a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx index 7ccbe33c493..5ad19559403 100644 --- a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx @@ -9,6 +9,8 @@ import { DeleteDashboardButton } from '../DeleteDashboard/DeleteDashboardButton' import { TimePickerSettings } from './TimePickerSettings'; import { updateTimeZoneDashboard, updateWeekStartDashboard } from 'app/features/dashboard/state/actions'; +import { PreviewSettings } from './PreviewSettings'; +import { config } from '@grafana/runtime'; interface OwnProps { dashboard: DashboardModel; @@ -120,6 +122,8 @@ export function GeneralSettingsUnconnected({ dashboard, updateTimeZone, updateWe + {config.featureToggles.dashboardPreviews && } + { + state: State = {}; + + doUpload = (evt: EventTarget & HTMLInputElement, isLight?: boolean) => { + const file = evt?.files && evt.files[0]; + if (!file) { + console.log('NOPE!', evt); + return; + } + + const url = getThumbnailURL(this.props.uid, isLight); + const formData = new FormData(); + formData.append('file', file); + + fetch(url, { + method: 'POST', + body: formData, + }) + .then((response) => response.json()) + .then((result) => { + console.log('Success:', result); + location.reload(); //HACK + }) + .catch((error) => { + console.error('Error:', error); + }); + }; + + render() { + const { uid } = this.props; + const imgstyle = { maxWidth: 300, maxHeight: 300 }; + return ( + +
DUMMY UI just so we have an upload button!
+ + + + + + + + + + + + + + + + + +
[DARK][LIGHT]
+ + + +
+ this.doUpload(currentTarget, false)} + > + Upload dark + + + this.doUpload(currentTarget, true)} + > + Upload light + +
+
+ ); + } +} diff --git a/public/app/features/search/components/ActionRow.tsx b/public/app/features/search/components/ActionRow.tsx index e85a1573e32..54b46fcdaa0 100644 --- a/public/app/features/search/components/ActionRow.tsx +++ b/public/app/features/search/components/ActionRow.tsx @@ -1,11 +1,12 @@ -import React, { FC, FormEvent } from 'react'; +import React, { FC, ChangeEvent, FormEvent } from 'react'; import { css } from '@emotion/css'; -import { HorizontalGroup, RadioButtonGroup, stylesFactory, useTheme, Checkbox } from '@grafana/ui'; +import { HorizontalGroup, RadioButtonGroup, stylesFactory, useTheme, Checkbox, InlineSwitch } from '@grafana/ui'; import { GrafanaTheme, SelectableValue } from '@grafana/data'; import { SortPicker } from 'app/core/components/Select/SortPicker'; import { TagFilter } from 'app/core/components/TagFilter/TagFilter'; import { SearchSrv } from 'app/core/services/search_srv'; import { DashboardQuery, SearchLayout } from '../types'; +import { config } from '@grafana/runtime'; export const layoutOptions = [ { value: SearchLayout.Folders, icon: 'folder', ariaLabel: 'View by folders' }, @@ -16,25 +17,30 @@ const searchSrv = new SearchSrv(); interface Props { onLayoutChange: (layout: SearchLayout) => void; + onShowPreviewsChange: (event: ChangeEvent) => void; onSortChange: (value: SelectableValue) => void; onStarredFilterChange?: (event: FormEvent) => void; onTagFilterChange: (tags: string[]) => void; query: DashboardQuery; showStarredFilter?: boolean; hideLayout?: boolean; + showPreviews?: boolean; } export const ActionRow: FC = ({ onLayoutChange, + onShowPreviewsChange, onSortChange, onStarredFilterChange = () => {}, onTagFilterChange, query, showStarredFilter, hideLayout, + showPreviews, }) => { const theme = useTheme(); const styles = getStyles(theme); + const previewsEnabled = config.featureToggles.dashboardPreviews; return (
@@ -44,6 +50,16 @@ export const ActionRow: FC = ({ ) : null} + {previewsEnabled && ( + + )}
diff --git a/public/app/features/search/components/DashboardSearch.tsx b/public/app/features/search/components/DashboardSearch.tsx index 90a973c2b31..cc054ea67b4 100644 --- a/public/app/features/search/components/DashboardSearch.tsx +++ b/public/app/features/search/components/DashboardSearch.tsx @@ -1,12 +1,15 @@ import React, { FC, memo } from 'react'; +import { useLocalStorage } from 'react-use'; import { css } from '@emotion/css'; import { useTheme2, CustomScrollbar, stylesFactory, IconButton } from '@grafana/ui'; import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { useSearchQuery } from '../hooks/useSearchQuery'; import { useDashboardSearch } from '../hooks/useDashboardSearch'; import { SearchField } from './SearchField'; import { SearchResults } from './SearchResults'; import { ActionRow } from './ActionRow'; +import { PREVIEWS_LOCAL_STORAGE_KEY } from '../constants'; export interface Props { onCloseSearch: () => void; @@ -17,6 +20,11 @@ export const DashboardSearch: FC = memo(({ onCloseSearch }) => { const { results, loading, onToggleSection, onKeyDown } = useDashboardSearch(query, onCloseSearch); const theme = useTheme2(); const styles = getStyles(theme); + const previewsEnabled = config.featureToggles.dashboardPreviews; + const [showPreviews, setShowPreviews] = useLocalStorage(PREVIEWS_LOCAL_STORAGE_KEY, previewsEnabled); + const onShowPreviewsChange = (event: React.ChangeEvent) => { + setShowPreviews(event.target.checked); + }; return (
@@ -31,9 +39,11 @@ export const DashboardSearch: FC = memo(({ onCloseSearch }) => { @@ -44,6 +54,7 @@ export const DashboardSearch: FC = memo(({ onCloseSearch }) => { editable={false} onToggleSection={onToggleSection} layout={query.layout} + showPreviews={showPreviews} />
diff --git a/public/app/features/search/components/ManageDashboards.tsx b/public/app/features/search/components/ManageDashboards.tsx index 3379e0b5869..7c74edded89 100644 --- a/public/app/features/search/components/ManageDashboards.tsx +++ b/public/app/features/search/components/ManageDashboards.tsx @@ -1,4 +1,5 @@ import React, { FC, memo, useState } from 'react'; +import { useLocalStorage } from 'react-use'; import { css } from '@emotion/css'; import { stylesFactory, useTheme, Spinner, FilterInput } from '@grafana/ui'; import { GrafanaTheme } from '@grafana/data'; @@ -13,6 +14,7 @@ import { useSearchQuery } from '../hooks/useSearchQuery'; import { SearchResultsFilter } from './SearchResultsFilter'; import { SearchResults } from './SearchResults'; import { DashboardActions } from './DashboardActions'; +import { PREVIEWS_LOCAL_STORAGE_KEY } from '../constants'; export interface Props { folder?: FolderDTO; @@ -21,6 +23,10 @@ export interface Props { const { isEditor } = contextSrv; export const ManageDashboards: FC = memo(({ folder }) => { + const [showPreviews, setShowPreviews] = useLocalStorage(PREVIEWS_LOCAL_STORAGE_KEY, true); + const onShowPreviewsChange = (event: React.ChangeEvent) => { + setShowPreviews(event.target.checked); + }; const folderId = folder?.id; const folderUid = folder?.uid; const theme = useTheme(); @@ -106,11 +112,13 @@ export const ManageDashboards: FC = memo(({ folder }) => { canMove={hasEditPermissionInFolders && canMove} deleteItem={onItemDelete} moveTo={onMoveTo} + onShowPreviewsChange={onShowPreviewsChange} onToggleAllChecked={onToggleAllChecked} onStarredFilterChange={onStarredFilterChange} onSortChange={onSortChange} onTagFilterChange={onTagFilterChange} query={query} + showPreviews={showPreviews} hideLayout={!!folderUid} onLayoutChange={onLayoutChange} editable={hasEditPermissionInFolders} @@ -123,6 +131,7 @@ export const ManageDashboards: FC = memo(({ folder }) => { onToggleSection={onToggleSection} onToggleChecked={onToggleChecked} layout={query.layout} + showPreviews={showPreviews} /> any; + onToggleChecked?: OnToggleChecked; +} + +export function getThumbnailURL(uid: string, isLight?: boolean) { + return `/api/dashboards/uid/${uid}/img/thumb/${isLight ? 'light' : 'dark'}`; +} + +export function SearchCard({ editable, item, onTagSelected, onToggleChecked }: Props) { + const [hasImage, setHasImage] = useState(true); + const [lastUpdated, setLastUpdated] = useState(); + const [showExpandedView, setShowExpandedView] = useState(false); + const timeout = useRef(null); + + // Popper specific logic + const offsetCallback = useCallback(({ placement, reference, popper }) => { + let result: [number, number] = [0, 0]; + if (placement === 'bottom' || placement === 'top') { + result = [0, -(reference.height + popper.height) / 2]; + } else if (placement === 'left' || placement === 'right') { + result = [-(reference.width + popper.width) / 2, 0]; + } + return result; + }, []); + const [markerElement, setMarkerElement] = React.useState(null); + const [popperElement, setPopperElement] = React.useState(null); + const { styles: popperStyles, attributes } = usePopper(markerElement, popperElement, { + modifiers: [ + { + name: 'offset', + options: { + offset: offsetCallback, + }, + }, + ], + }); + + const theme = useTheme2(); + const imageSrc = getThumbnailURL(item.uid!, theme.isLight); + const styles = getStyles( + theme, + markerElement?.getBoundingClientRect().width, + popperElement?.getBoundingClientRect().width + ); + + const onShowExpandedView = async () => { + setShowExpandedView(true); + if (item.uid && !lastUpdated) { + const dashboard = await backendSrv.getDashboardByUid(item.uid); + const { updated } = dashboard.meta; + setLastUpdated(new Date(updated).toLocaleString()); + } + }; + + const onMouseEnter = () => { + timeout.current = window.setTimeout(onShowExpandedView, DELAY_BEFORE_EXPANDING); + }; + + const onMouseMove = () => { + if (timeout.current) { + window.clearTimeout(timeout.current); + } + timeout.current = window.setTimeout(onShowExpandedView, DELAY_BEFORE_EXPANDING); + }; + + const onMouseLeave = () => { + if (timeout.current) { + window.clearTimeout(timeout.current); + } + setShowExpandedView(false); + }; + + const onCheckboxClick = (ev: React.MouseEvent) => { + ev.stopPropagation(); + ev.preventDefault(); + + onToggleChecked?.(item); + }; + + const onTagClick = (tag: string, ev: React.MouseEvent) => { + ev.stopPropagation(); + ev.preventDefault(); + + onTagSelected?.(tag); + }; + + return ( + setMarkerElement((ref as unknown) as HTMLDivElement)} + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} + onMouseMove={onMouseMove} + > +
+ + {hasImage ? ( + setHasImage(false)} /> + ) : ( +
+ +
+ )} +
+
+
{item.title}
+ +
+ {showExpandedView && ( + +
+ +
+
+ )} +
+ ); +} + +const getStyles = (theme: GrafanaTheme2, markerWidth = 0, popperWidth = 0) => { + const IMAGE_HORIZONTAL_MARGIN = theme.spacing(4); + + return { + card: css` + background-color: ${theme.colors.background.secondary}; + border: 1px solid ${theme.colors.border.medium}; + border-radius: 4px; + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + + &:hover { + background-color: ${theme.colors.emphasize(theme.colors.background.secondary, 0.03)}; + } + `, + checkbox: css` + left: 0; + margin: ${theme.spacing(1)}; + position: absolute; + top: 0; + `, + expandedView: css` + @keyframes expand { + 0% { + transform: scale(${markerWidth / popperWidth}); + } + 100% { + transform: scale(1); + } + } + + animation: expand ${theme.transitions.duration.shortest}ms ease-in-out 0s 1 normal; + background-color: ${theme.colors.emphasize(theme.colors.background.secondary, 0.03)}; + `, + image: css` + aspect-ratio: 4 / 3; + box-shadow: ${theme.shadows.z1}; + margin: ${theme.spacing(1)} ${IMAGE_HORIZONTAL_MARGIN} 0; + width: calc(100% - (2 * ${IMAGE_HORIZONTAL_MARGIN})); + `, + imageContainer: css` + flex: 1; + position: relative; + + &:after { + background: linear-gradient(180deg, rgba(196, 196, 196, 0) 0%, rgba(127, 127, 127, 0.25) 100%); + bottom: 0; + content: ''; + left: 0; + margin: ${theme.spacing(1)} ${IMAGE_HORIZONTAL_MARGIN} 0; + position: absolute; + right: 0; + top: 0; + } + `, + imagePlaceholder: css` + align-items: center; + aspect-ratio: 4 / 3; + color: ${theme.colors.text.secondary}; + display: flex; + justify-content: center; + margin: ${theme.spacing(1)} ${IMAGE_HORIZONTAL_MARGIN} 0; + width: calc(100% - (2 * ${IMAGE_HORIZONTAL_MARGIN})); + `, + info: css` + align-items: center; + background-color: ${theme.colors.background.canvas}; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + display: flex; + height: ${theme.spacing(7)}; + gap: ${theme.spacing(1)}; + padding: 0 ${theme.spacing(2)}; + z-index: 1; + `, + portal: css` + pointer-events: none; + `, + title: css` + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + `, + }; +}; diff --git a/public/app/features/search/components/SearchCardExpanded.tsx b/public/app/features/search/components/SearchCardExpanded.tsx new file mode 100644 index 00000000000..f83b7d807b6 --- /dev/null +++ b/public/app/features/search/components/SearchCardExpanded.tsx @@ -0,0 +1,153 @@ +import React, { useState } from 'react'; +import { css } from '@emotion/css'; +import classNames from 'classnames'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Icon, Spinner, TagList, useTheme2 } from '@grafana/ui'; +import { DashboardSectionItem } from '../types'; +import { getThumbnailURL } from './SearchCard'; + +export interface Props { + className?: string; + imageHeight: number; + imageWidth: number; + item: DashboardSectionItem; + lastUpdated?: string; +} + +export function SearchCardExpanded({ className, imageHeight, imageWidth, item, lastUpdated }: Props) { + const theme = useTheme2(); + const [hasImage, setHasImage] = useState(true); + const imageSrc = getThumbnailURL(item.uid!, theme.isLight); + const styles = getStyles(theme, imageHeight, imageWidth); + + const folderTitle = item.folderTitle || 'General'; + + return ( + +
+ {hasImage ? ( + setHasImage(true)} + onError={() => setHasImage(false)} + /> + ) : ( +
+ +
+ )} +
+
+
+
+
{item.title}
+
+ + {folderTitle} +
+
+
+
Last updated
+ {lastUpdated ?
{lastUpdated}
: } +
+
+
+ +
+
+
+ ); +} + +const getStyles = (theme: GrafanaTheme2, imageHeight: Props['imageHeight'], imageWidth: Props['imageWidth']) => { + const IMAGE_HORIZONTAL_MARGIN = theme.spacing(4); + + return { + card: css` + background-color: ${theme.colors.background.secondary}; + border: 1px solid ${theme.colors.border.medium}; + border-radius: 4px; + box-shadow: ${theme.shadows.z3}; + display: flex; + flex-direction: column; + height: 100%; + max-width: calc(${imageWidth}px + (${IMAGE_HORIZONTAL_MARGIN} * 2))}; + width: 100%; + `, + folder: css` + align-items: center; + color: ${theme.colors.text.secondary}; + display: flex; + font-size: ${theme.typography.size.sm}; + gap: ${theme.spacing(0.5)}; + `, + image: css` + box-shadow: ${theme.shadows.z2}; + height: ${imageHeight}px; + margin: ${theme.spacing(1)} calc(${IMAGE_HORIZONTAL_MARGIN} - 1px) 0; + width: ${imageWidth}px; + `, + imageContainer: css` + flex: 1; + position: relative; + + &:after { + background: linear-gradient(180deg, rgba(196, 196, 196, 0) 0%, rgba(127, 127, 127, 0.25) 100%); + bottom: 0; + content: ''; + left: 0; + margin: ${theme.spacing(1)} calc(${IMAGE_HORIZONTAL_MARGIN} - 1px) 0; + position: absolute; + right: 0; + top: 0; + } + `, + imagePlaceholder: css` + align-items: center; + color: ${theme.colors.text.secondary}; + display: flex; + height: ${imageHeight}px; + justify-content: center; + margin: ${theme.spacing(1)} ${IMAGE_HORIZONTAL_MARGIN} 0; + width: ${imageWidth}px; + `, + info: css` + background-color: ${theme.colors.background.canvas}; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + display: flex; + flex-direction: column; + min-height: ${theme.spacing(7)}; + gap: ${theme.spacing(1)}; + padding: ${theme.spacing(1)} ${theme.spacing(2)}; + z-index: 1; + `, + infoHeader: css` + display: flex; + gap: ${theme.spacing(1)}; + justify-content: space-between; + `, + tagList: css` + justify-content: flex-start; + `, + titleContainer: css` + display: flex; + flex-direction: column; + gap: ${theme.spacing(0.5)}; + `, + updateContainer: css` + align-items: flex-end; + display: flex; + flex-direction: column; + flex-shrink: 0; + font-size: ${theme.typography.bodySmall.fontSize}; + gap: ${theme.spacing(0.5)}; + `, + update: css` + color: ${theme.colors.text.secondary}; + text-align: right; + `, + }; +}; diff --git a/public/app/features/search/components/SearchResults.test.tsx b/public/app/features/search/components/SearchResults.test.tsx index 74b4f22677e..1ca4924bb82 100644 --- a/public/app/features/search/components/SearchResults.test.tsx +++ b/public/app/features/search/components/SearchResults.test.tsx @@ -32,10 +32,28 @@ describe('SearchResults', () => { it('should render section items for expanded section', () => { setup(); - expect(screen.getAllByTestId(selectors.components.Search.collapseFolder('0'))).toHaveLength(1); - expect(screen.getAllByTestId(selectors.components.Search.itemsV2)).toHaveLength(1); - expect(screen.getAllByTestId(selectors.components.Search.dashboardItem('Test 1'))).toHaveLength(1); - expect(screen.getAllByTestId(selectors.components.Search.dashboardItem('Test 2'))).toHaveLength(1); + expect(screen.getByTestId(selectors.components.Search.collapseFolder('0'))).toBeInTheDocument(); + expect(screen.getByTestId(selectors.components.Search.itemsV2)).toBeInTheDocument(); + expect(screen.getByTestId(selectors.components.Search.dashboardItem('Test 1'))).toBeInTheDocument(); + expect(screen.getByTestId(selectors.components.Search.dashboardItem('Test 2'))).toBeInTheDocument(); + + // Check search cards aren't in the DOM + expect(screen.queryByTestId(selectors.components.Search.cards)).not.toBeInTheDocument(); + expect(screen.queryByTestId(selectors.components.Search.dashboardCard('Test 1'))).not.toBeInTheDocument(); + expect(screen.queryByTestId(selectors.components.Search.dashboardCard('Test 2'))).not.toBeInTheDocument(); + }); + + it('should render search card items for expanded section when showPreviews is enabled', () => { + setup({ showPreviews: true }); + expect(screen.getByTestId(selectors.components.Search.collapseFolder('0'))).toBeInTheDocument(); + expect(screen.getByTestId(selectors.components.Search.cards)).toBeInTheDocument(); + expect(screen.getByTestId(selectors.components.Search.dashboardCard('Test 1'))).toBeInTheDocument(); + expect(screen.getByTestId(selectors.components.Search.dashboardCard('Test 2'))).toBeInTheDocument(); + + // Check search items aren't in the DOM + expect(screen.queryByTestId(selectors.components.Search.itemsV2)).not.toBeInTheDocument(); + expect(screen.queryByTestId(selectors.components.Search.dashboardItem('Test 1'))).not.toBeInTheDocument(); + expect(screen.queryByTestId(selectors.components.Search.dashboardItem('Test 2'))).not.toBeInTheDocument(); }); it('should not render checkboxes for non-editable results', () => { diff --git a/public/app/features/search/components/SearchResults.tsx b/public/app/features/search/components/SearchResults.tsx index 58545d611c6..d99c2904d8c 100644 --- a/public/app/features/search/components/SearchResults.tsx +++ b/public/app/features/search/components/SearchResults.tsx @@ -1,6 +1,7 @@ import React, { FC, memo } from 'react'; import { css } from '@emotion/css'; -import { FixedSizeList } from 'react-window'; +import classNames from 'classnames'; +import { FixedSizeList, FixedSizeGrid } from 'react-window'; import AutoSizer from 'react-virtualized-auto-sizer'; import { GrafanaTheme } from '@grafana/data'; import { Spinner, stylesFactory, useTheme } from '@grafana/ui'; @@ -8,6 +9,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { DashboardSection, OnToggleChecked, SearchLayout } from '../types'; import { SEARCH_ITEM_HEIGHT, SEARCH_ITEM_MARGIN } from '../constants'; import { SearchItem } from './SearchItem'; +import { SearchCard } from './SearchCard'; import { SectionHeader } from './SectionHeader'; export interface Props { @@ -17,13 +19,14 @@ export interface Props { onToggleChecked?: OnToggleChecked; onToggleSection: (section: DashboardSection) => void; results: DashboardSection[]; + showPreviews?: boolean; layout?: string; } -const { sectionV2: sectionLabel, itemsV2: itemsLabel } = selectors.components.Search; +const { sectionV2: sectionLabel, itemsV2: itemsLabel, cards: cardsLabel } = selectors.components.Search; export const SearchResults: FC = memo( - ({ editable, loading, onTagSelected, onToggleChecked, onToggleSection, results, layout }) => { + ({ editable, loading, onTagSelected, onToggleChecked, onToggleSection, results, showPreviews, layout }) => { const theme = useTheme(); const styles = getSectionStyles(theme); const itemProps = { editable, onToggleChecked, onTagSelected }; @@ -36,13 +39,20 @@ export const SearchResults: FC = memo( {section.title && ( )} - {section.expanded && ( -
- {section.items.map((item) => ( - - ))} -
- )} + {section.expanded && + (showPreviews ? ( +
+ {section.items.map((item) => ( + + ))} +
+ ) : ( +
+ {section.items.map((item) => ( + + ))} +
+ ))} ); })} @@ -53,28 +63,57 @@ export const SearchResults: FC = memo( const items = results[0]?.items; return (
- - {({ height }) => ( - - {({ index, style }) => { - const item = items[index]; - // The wrapper div is needed as the inner SearchItem has margin-bottom spacing - // And without this wrapper there is no room for that margin - return ( -
  • - -
  • - ); - }} -
    - )} + + {({ height, width }) => { + const numColumns = Math.ceil(width / 320); + const cellWidth = width / numColumns; + const cellHeight = (cellWidth - 64) * 0.75 + 56 + 8; + const numRows = Math.ceil(items.length / numColumns); + return showPreviews ? ( + + {({ columnIndex, rowIndex, style }) => { + const index = rowIndex * numColumns + columnIndex; + const item = items[index]; + // The wrapper div is needed as the inner SearchItem has margin-bottom spacing + // And without this wrapper there is no room for that margin + return item ? ( +
  • + +
  • + ) : null; + }} +
    + ) : ( + + {({ index, style }) => { + const item = items[index]; + // The wrapper div is needed as the inner SearchItem has margin-bottom spacing + // And without this wrapper there is no room for that margin + return ( +
  • + +
  • + ); + }} +
    + ); + }}
    ); @@ -97,12 +136,19 @@ export const SearchResults: FC = memo( SearchResults.displayName = 'SearchResults'; const getSectionStyles = stylesFactory((theme: GrafanaTheme) => { - const { md } = theme.spacing; + const { md, sm } = theme.spacing; return { + virtualizedGridItemWrapper: css` + padding: 4px; + `, wrapper: css` display: flex; flex-direction: column; + + > ul { + list-style: none; + } `, section: css` display: flex; @@ -119,6 +165,12 @@ const getSectionStyles = stylesFactory((theme: GrafanaTheme) => { align-items: center; min-height: 100px; `, + gridContainer: css` + display: grid; + gap: ${sm}; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + margin-bottom: ${md}; + `, resultsContainer: css` position: relative; flex-grow: 10; diff --git a/public/app/features/search/components/SearchResultsFilter.test.tsx b/public/app/features/search/components/SearchResultsFilter.test.tsx index b2045031beb..798636be17d 100644 --- a/public/app/features/search/components/SearchResultsFilter.test.tsx +++ b/public/app/features/search/components/SearchResultsFilter.test.tsx @@ -37,6 +37,7 @@ const setup = (propOverrides?: Partial) => { onLayoutChange: noop, query: searchQuery, onSortChange: noop, + onShowPreviewsChange: noop, editable: true, }; diff --git a/public/app/features/search/components/SearchResultsFilter.tsx b/public/app/features/search/components/SearchResultsFilter.tsx index d1ea6ed9b62..70b7cd33e74 100644 --- a/public/app/features/search/components/SearchResultsFilter.tsx +++ b/public/app/features/search/components/SearchResultsFilter.tsx @@ -1,4 +1,4 @@ -import React, { FC, FormEvent } from 'react'; +import React, { FC, ChangeEvent, FormEvent } from 'react'; import { css } from '@emotion/css'; import { Button, Checkbox, stylesFactory, useTheme, HorizontalGroup } from '@grafana/ui'; import { GrafanaTheme, SelectableValue } from '@grafana/data'; @@ -13,11 +13,13 @@ export interface Props { hideLayout?: boolean; moveTo: () => void; onLayoutChange: (layout: SearchLayout) => void; + onShowPreviewsChange: (event: ChangeEvent) => void; onSortChange: (value: SelectableValue) => void; onStarredFilterChange: (event: FormEvent) => void; onTagFilterChange: (tags: string[]) => void; onToggleAllChecked: () => void; query: DashboardQuery; + showPreviews?: boolean; editable?: boolean; } @@ -29,11 +31,13 @@ export const SearchResultsFilter: FC = ({ hideLayout, moveTo, onLayoutChange, + onShowPreviewsChange, onSortChange, onStarredFilterChange, onTagFilterChange, onToggleAllChecked, query, + showPreviews, editable, }) => { const showActions = canDelete || canMove; @@ -61,10 +65,12 @@ export const SearchResultsFilter: FC = ({ {...{ hideLayout, onLayoutChange, + onShowPreviewsChange, onSortChange, onStarredFilterChange, onTagFilterChange, query, + showPreviews, }} showStarredFilter /> diff --git a/public/app/features/search/constants.ts b/public/app/features/search/constants.ts index edb07eeb01a..b39269716db 100644 --- a/public/app/features/search/constants.ts +++ b/public/app/features/search/constants.ts @@ -6,3 +6,4 @@ export const DEFAULT_SORT = { label: 'A\u2013Z', value: 'alpha-asc' }; export const SECTION_STORAGE_KEY = 'search.sections'; export const GENERAL_FOLDER_ID = 0; export const GENERAL_FOLDER_TITLE = 'General'; +export const PREVIEWS_LOCAL_STORAGE_KEY = 'grafana.dashboard.previews'; diff --git a/public/app/plugins/panel/dashlist/DashList.tsx b/public/app/plugins/panel/dashlist/DashList.tsx index 861fa0f42a6..b31f6eaa1c1 100644 --- a/public/app/plugins/panel/dashlist/DashList.tsx +++ b/public/app/plugins/panel/dashlist/DashList.tsx @@ -11,7 +11,8 @@ import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import impressionSrv from 'app/core/services/impression_srv'; import { DashboardSearchHit } from 'app/features/search/types'; import { getStyles } from './styles'; -import { PanelOptions } from './models.gen'; +import { PanelLayout, PanelOptions } from './models.gen'; +import { SearchCard } from 'app/features/search/components/SearchCard'; type Dashboard = DashboardSearchHit & { isSearchResult?: boolean; isRecent?: boolean }; @@ -105,7 +106,7 @@ export function DashList(props: PanelProps) { ]; }, [dashboards]); - const { showStarred, showRecentlyViewed, showHeadings, showSearch } = props.options; + const { showStarred, showRecentlyViewed, showHeadings, showSearch, layout } = props.options; const dashboardGroups: DashboardGroup[] = [ { @@ -152,6 +153,16 @@ export function DashList(props: PanelProps) { ); + const renderPreviews = (dashboards: Dashboard[]) => ( +
      + {dashboards.map((dash) => ( +
    • + +
    • + ))} +
    + ); + return ( {dashboardGroups.map( @@ -159,7 +170,7 @@ export function DashList(props: PanelProps) { show && (
    {showHeadings &&
    {header}
    } - {renderList(dashboards)} + {layout === PanelLayout.Previews ? renderPreviews(dashboards) : renderList(dashboards)}
    ) )} diff --git a/public/app/plugins/panel/dashlist/models.cue b/public/app/plugins/panel/dashlist/models.cue index 731e1d07dc3..153f609e9c9 100644 --- a/public/app/plugins/panel/dashlist/models.cue +++ b/public/app/plugins/panel/dashlist/models.cue @@ -19,6 +19,7 @@ Panel: { [ { PanelOptions: { + layout?: *"list" | "previews" showStarred: bool | *true showRecentlyViewed: bool | *false showSearch: bool | *false diff --git a/public/app/plugins/panel/dashlist/models.gen.ts b/public/app/plugins/panel/dashlist/models.gen.ts index 7ab7a3db72d..013e89cdd44 100644 --- a/public/app/plugins/panel/dashlist/models.gen.ts +++ b/public/app/plugins/panel/dashlist/models.gen.ts @@ -4,7 +4,13 @@ export const modelVersion = Object.freeze([0, 0]); +export enum PanelLayout { + List = 'list', + Previews = 'previews', +} + export interface PanelOptions { + layout?: PanelLayout; folderId?: number; maxItems: number; query: string; @@ -16,6 +22,7 @@ export interface PanelOptions { } export const defaultPanelOptions: PanelOptions = { + layout: PanelLayout.List, maxItems: 10, query: '', showHeadings: true, diff --git a/public/app/plugins/panel/dashlist/module.tsx b/public/app/plugins/panel/dashlist/module.tsx index 12f935edd5b..742e48d2eb0 100644 --- a/public/app/plugins/panel/dashlist/module.tsx +++ b/public/app/plugins/panel/dashlist/module.tsx @@ -7,10 +7,25 @@ import { GENERAL_FOLDER, ReadonlyFolderPicker, } from '../../../core/components/Select/ReadonlyFolderPicker/ReadonlyFolderPicker'; -import { defaultPanelOptions, PanelOptions } from './models.gen'; +import { config } from '@grafana/runtime'; +import { defaultPanelOptions, PanelLayout, PanelOptions } from './models.gen'; export const plugin = new PanelPlugin(DashList) .setPanelOptions((builder) => { + if (config.featureToggles.dashboardPreviews) { + builder.addRadio({ + path: 'layout', + name: 'Layout', + defaultValue: PanelLayout.List, + settings: { + options: [ + { value: PanelLayout.List, label: 'List' }, + { value: PanelLayout.Previews, label: 'Preview' }, + ], + }, + }); + } + builder .addBooleanSwitch({ path: 'showStarred', diff --git a/public/app/plugins/panel/dashlist/styles.ts b/public/app/plugins/panel/dashlist/styles.ts index 525f79d0cd2..c97c5fb410d 100644 --- a/public/app/plugins/panel/dashlist/styles.ts +++ b/public/app/plugins/panel/dashlist/styles.ts @@ -54,4 +54,12 @@ export const getStyles = (theme: GrafanaTheme2) => ({ position: relative; list-style: none; `, + + gridContainer: css` + display: grid; + gap: ${theme.spacing(1)}; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + list-style: none; + margin-bottom: ${theme.spacing(1)}; + `, });