DashboardPreviews: add dashboard previews behind feature flag (#43226)

Co-authored-by: Ashley Harrison <ashley.harrison@grafana.com>
Co-authored-by: Artur Wierzbicki <artur@arturwierzbicki.com>
This commit is contained in:
Ryan McKinley 2021-12-23 09:43:53 -08:00 committed by GitHub
parent 545d6c4ddb
commit 4233a62aeb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1214 additions and 55 deletions

3
.github/CODEOWNERS vendored
View File

@ -64,6 +64,9 @@ go.sum @grafana/backend-platform
/pkg/services/datasourceproxy @grafana/plugins-platform-backend /pkg/services/datasourceproxy @grafana/plugins-platform-backend
/pkg/services/datasources @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 # Backend code docs
/contribute/style-guides/backend.md @grafana/backend-platform /contribute/style-guides/backend.md @grafana/backend-platform
/contribute/architecture/backend @grafana/backend-platform /contribute/architecture/backend @grafana/backend-platform

View File

@ -52,6 +52,7 @@ export interface FeatureToggles {
recordedQueries: boolean; recordedQueries: boolean;
newNavigation: boolean; newNavigation: boolean;
fullRangeLogsVolume: boolean; fullRangeLogsVolume: boolean;
dashboardPreviews: boolean;
} }
/** /**

View File

@ -274,9 +274,11 @@ export const Components = {
*/ */
items: 'Search items', items: 'Search items',
itemsV2: 'data-testid Search items', itemsV2: 'data-testid Search items',
cards: 'data-testid Search cards',
collapseFolder: (sectionId: string) => `data-testid Collapse folder ${sectionId}`, collapseFolder: (sectionId: string) => `data-testid Collapse folder ${sectionId}`,
expandFolder: (sectionId: string) => `data-testid Expand folder ${sectionId}`, expandFolder: (sectionId: string) => `data-testid Expand folder ${sectionId}`,
dashboardItem: (item: string) => `${Components.Search.dashboardItems} ${item}`, dashboardItem: (item: string) => `${Components.Search.dashboardItems} ${item}`,
dashboardCard: (item: string) => `data-testid Search card ${item}`,
dashboardItems: 'data-testid Dashboard search item', dashboardItems: 'data-testid Dashboard search item',
}, },
DashboardLinks: { DashboardLinks: {

View File

@ -69,6 +69,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
recordedQueries: false, recordedQueries: false,
newNavigation: false, newNavigation: false,
fullRangeLogsVolume: false, fullRangeLogsVolume: false,
dashboardPreviews: false,
}; };
licenseInfo: LicenseInfo = {} as LicenseInfo; licenseInfo: LicenseInfo = {} as LicenseInfo;
rendererAvailable = false; rendererAvailable = false;

View File

@ -1,39 +1,49 @@
import React, { FC, memo } from 'react'; import React, { FC, memo } from 'react';
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { OnTagClick, Tag } from './Tag'; import { OnTagClick, Tag } from './Tag';
import { useTheme2 } from '../../themes';
import { GrafanaTheme2 } from '@grafana/data';
export interface Props { export interface Props {
displayMax?: number;
tags: string[]; tags: string[];
onClick?: OnTagClick; onClick?: OnTagClick;
/** Custom styles for the wrapper component */ /** Custom styles for the wrapper component */
className?: string; className?: string;
} }
export const TagList: FC<Props> = memo(({ tags, onClick, className }) => { export const TagList: FC<Props> = memo(({ displayMax, tags, onClick, className }) => {
const styles = getStyles(); const theme = useTheme2();
const styles = getStyles(theme, Boolean(displayMax && displayMax > 0));
const numTags = tags.length;
const tagsToDisplay = displayMax ? tags.slice(0, displayMax) : tags;
return ( return (
<span className={cx(styles.wrapper, className)}> <span className={cx(styles.wrapper, className)}>
{tags.map((tag) => ( {tagsToDisplay.map((tag) => (
<Tag key={tag} name={tag} onClick={onClick} className={styles.tag} /> <Tag key={tag} name={tag} onClick={onClick} />
))} ))}
{displayMax && displayMax > 0 && numTags - 1 > 0 && <span className={styles.moreTagsLabel}>+ {numTags - 1}</span>}
</span> </span>
); );
}); });
TagList.displayName = 'TagList'; TagList.displayName = 'TagList';
const getStyles = () => { const getStyles = (theme: GrafanaTheme2, isTruncated: boolean) => {
return { return {
wrapper: css` wrapper: css`
align-items: ${isTruncated ? 'center' : 'unset'};
display: flex; display: flex;
flex: 1 1 auto; flex: 1 1 auto;
flex-wrap: wrap; flex-wrap: wrap;
margin-bottom: -6px; flex-shrink: ${isTruncated ? 0 : 1};
justify-content: flex-end; justify-content: flex-end;
gap: 6px;
`, `,
tag: css` moreTagsLabel: css`
margin: 0 0 6px 6px; color: ${theme.colors.text.secondary};
font-size: ${theme.typography.size.sm};
`, `,
}; };
}; };

View File

@ -327,6 +327,11 @@ func (hs *HTTPServer) registerRoutes() {
dashboardRoute.Get("/uid/:uid", routing.Wrap(hs.GetDashboard)) dashboardRoute.Get("/uid/:uid", routing.Wrap(hs.GetDashboard))
dashboardRoute.Delete("/uid/:uid", routing.Wrap(hs.DeleteDashboardByUID)) 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("/calculate-diff", routing.Wrap(CalculateDashboardDiff))
dashboardRoute.Post("/trim", routing.Wrap(hs.TrimDashboard)) dashboardRoute.Post("/trim", routing.Wrap(hs.TrimDashboard))

View File

@ -14,6 +14,7 @@ import (
"sync" "sync"
"github.com/grafana/grafana/pkg/services/query" "github.com/grafana/grafana/pkg/services/query"
"github.com/grafana/grafana/pkg/services/thumbs"
"github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/api/routing"
httpstatic "github.com/grafana/grafana/pkg/api/static" httpstatic "github.com/grafana/grafana/pkg/api/static"
@ -94,6 +95,7 @@ type HTTPServer struct {
ShortURLService shorturls.Service ShortURLService shorturls.Service
Live *live.GrafanaLive Live *live.GrafanaLive
LivePushGateway *pushhttp.Gateway LivePushGateway *pushhttp.Gateway
ThumbService thumbs.Service
ContextHandler *contexthandler.ContextHandler ContextHandler *contexthandler.ContextHandler
SQLStore *sqlstore.SQLStore SQLStore *sqlstore.SQLStore
AlertEngine *alerting.AlertEngine 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, pluginDashboardManager plugins.PluginDashboardManager, pluginStore plugins.Store, pluginClient plugins.Client,
pluginErrorResolver plugins.ErrorResolver, settingsProvider setting.Provider, pluginErrorResolver plugins.ErrorResolver, settingsProvider setting.Provider,
dataSourceCache datasources.CacheService, userTokenService models.UserTokenService, 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, remoteCache *remotecache.RemoteCache, provisioningService provisioning.ProvisioningService,
loginService login.Service, accessControl accesscontrol.AccessControl, loginService login.Service, accessControl accesscontrol.AccessControl,
dataSourceProxy *datasourceproxy.DataSourceProxyService, searchService *search.SearchService, dataSourceProxy *datasourceproxy.DataSourceProxyService, searchService *search.SearchService,
@ -161,6 +163,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
AuthTokenService: userTokenService, AuthTokenService: userTokenService,
cleanUpService: cleanUpService, cleanUpService: cleanUpService,
ShortURLService: shortURLService, ShortURLService: shortURLService,
ThumbService: thumbService,
RemoteCacheService: remoteCache, RemoteCacheService: remoteCache,
ProvisioningService: provisioningService, ProvisioningService: provisioningService,
Login: loginService, Login: loginService,

View File

@ -59,6 +59,7 @@ import (
serviceaccountsmanager "github.com/grafana/grafana/pkg/services/serviceaccounts/manager" serviceaccountsmanager "github.com/grafana/grafana/pkg/services/serviceaccounts/manager"
"github.com/grafana/grafana/pkg/services/shorturls" "github.com/grafana/grafana/pkg/services/shorturls"
"github.com/grafana/grafana/pkg/services/sqlstore" "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/services/updatechecker"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor" "github.com/grafana/grafana/pkg/tsdb/azuremonitor"
@ -91,6 +92,7 @@ var wireBasicSet = wire.NewSet(
query.ProvideService, query.ProvideService,
bus.ProvideBus, bus.ProvideBus,
wire.Bind(new(bus.Bus), new(*bus.InProcBus)), wire.Bind(new(bus.Bus), new(*bus.InProcBus)),
thumbs.ProvideService,
rendering.ProvideService, rendering.ProvideService,
wire.Bind(new(rendering.Service), new(*rendering.RenderingService)), wire.Bind(new(rendering.Service), new(*rendering.RenderingService)),
routing.ProvideRegister, routing.ProvideRegister,

View File

@ -56,9 +56,12 @@ func (*OSSMigrations) AddMigration(mg *Migrator) {
ualert.AddTablesMigrations(mg) ualert.AddTablesMigrations(mg)
ualert.AddDashAlertMigration(mg) ualert.AddDashAlertMigration(mg)
addLibraryElementsMigrations(mg) addLibraryElementsMigrations(mg)
if mg.Cfg != nil || mg.Cfg.IsLiveConfigEnabled() { if mg.Cfg != nil {
if mg.Cfg.IsLiveConfigEnabled() {
addLiveChannelMigrations(mg) addLiveChannelMigrations(mg)
} }
}
ualert.RerunDashAlertMigration(mg) ualert.RerunDashAlertMigration(mg)
addSecretsMigration(mg) addSecretsMigration(mg)
addKVStoreMigrations(mg) addKVStoreMigrations(mg)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -433,6 +433,11 @@ func (cfg Cfg) IsLiveConfigEnabled() bool {
return cfg.FeatureToggles["live-config"] 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. // IsTrimDefaultsEnabled returns whether the standalone trim dashboard default feature is enabled.
func (cfg Cfg) IsTrimDefaultsEnabled() bool { func (cfg Cfg) IsTrimDefaultsEnabled() bool {
return cfg.FeatureToggles["trimDefaults"] return cfg.FeatureToggles["trimDefaults"]

View File

@ -9,6 +9,8 @@ import { DeleteDashboardButton } from '../DeleteDashboard/DeleteDashboardButton'
import { TimePickerSettings } from './TimePickerSettings'; import { TimePickerSettings } from './TimePickerSettings';
import { updateTimeZoneDashboard, updateWeekStartDashboard } from 'app/features/dashboard/state/actions'; import { updateTimeZoneDashboard, updateWeekStartDashboard } from 'app/features/dashboard/state/actions';
import { PreviewSettings } from './PreviewSettings';
import { config } from '@grafana/runtime';
interface OwnProps { interface OwnProps {
dashboard: DashboardModel; dashboard: DashboardModel;
@ -120,6 +122,8 @@ export function GeneralSettingsUnconnected({ dashboard, updateTimeZone, updateWe
</Field> </Field>
</div> </div>
{config.featureToggles.dashboardPreviews && <PreviewSettings uid={dashboard.uid} />}
<TimePickerSettings <TimePickerSettings
onTimeZoneChange={onTimeZoneChange} onTimeZoneChange={onTimeZoneChange}
onWeekStartChange={onWeekStartChange} onWeekStartChange={onWeekStartChange}

View File

@ -0,0 +1,84 @@
import React, { PureComponent } from 'react';
import { CollapsableSection, FileUpload } from '@grafana/ui';
import { getThumbnailURL } from 'app/features/search/components/SearchCard';
interface Props {
uid: string;
}
interface State {}
export class PreviewSettings extends PureComponent<Props, State> {
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 (
<CollapsableSection label="Preview settings" isOpen={true}>
<div>DUMMY UI just so we have an upload button!</div>
<table cellSpacing="4">
<thead>
<tr>
<td>[DARK]</td>
<td>[LIGHT]</td>
</tr>
</thead>
<tbody>
<tr>
<td>
<img src={getThumbnailURL(uid, false)} style={imgstyle} />
</td>
<td>
<img src={getThumbnailURL(uid, true)} style={imgstyle} />
</td>
</tr>
<tr>
<td>
<FileUpload
accept="image/png, image/webp"
onFileUpload={({ currentTarget }) => this.doUpload(currentTarget, false)}
>
Upload dark
</FileUpload>
</td>
<td>
<FileUpload
accept="image/png, image/webp"
onFileUpload={({ currentTarget }) => this.doUpload(currentTarget, true)}
>
Upload light
</FileUpload>
</td>
</tr>
</tbody>
</table>
</CollapsableSection>
);
}
}

View File

@ -1,11 +1,12 @@
import React, { FC, FormEvent } from 'react'; import React, { FC, ChangeEvent, FormEvent } from 'react';
import { css } from '@emotion/css'; 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 { GrafanaTheme, SelectableValue } from '@grafana/data';
import { SortPicker } from 'app/core/components/Select/SortPicker'; import { SortPicker } from 'app/core/components/Select/SortPicker';
import { TagFilter } from 'app/core/components/TagFilter/TagFilter'; import { TagFilter } from 'app/core/components/TagFilter/TagFilter';
import { SearchSrv } from 'app/core/services/search_srv'; import { SearchSrv } from 'app/core/services/search_srv';
import { DashboardQuery, SearchLayout } from '../types'; import { DashboardQuery, SearchLayout } from '../types';
import { config } from '@grafana/runtime';
export const layoutOptions = [ export const layoutOptions = [
{ value: SearchLayout.Folders, icon: 'folder', ariaLabel: 'View by folders' }, { value: SearchLayout.Folders, icon: 'folder', ariaLabel: 'View by folders' },
@ -16,25 +17,30 @@ const searchSrv = new SearchSrv();
interface Props { interface Props {
onLayoutChange: (layout: SearchLayout) => void; onLayoutChange: (layout: SearchLayout) => void;
onShowPreviewsChange: (event: ChangeEvent<HTMLInputElement>) => void;
onSortChange: (value: SelectableValue) => void; onSortChange: (value: SelectableValue) => void;
onStarredFilterChange?: (event: FormEvent<HTMLInputElement>) => void; onStarredFilterChange?: (event: FormEvent<HTMLInputElement>) => void;
onTagFilterChange: (tags: string[]) => void; onTagFilterChange: (tags: string[]) => void;
query: DashboardQuery; query: DashboardQuery;
showStarredFilter?: boolean; showStarredFilter?: boolean;
hideLayout?: boolean; hideLayout?: boolean;
showPreviews?: boolean;
} }
export const ActionRow: FC<Props> = ({ export const ActionRow: FC<Props> = ({
onLayoutChange, onLayoutChange,
onShowPreviewsChange,
onSortChange, onSortChange,
onStarredFilterChange = () => {}, onStarredFilterChange = () => {},
onTagFilterChange, onTagFilterChange,
query, query,
showStarredFilter, showStarredFilter,
hideLayout, hideLayout,
showPreviews,
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const styles = getStyles(theme); const styles = getStyles(theme);
const previewsEnabled = config.featureToggles.dashboardPreviews;
return ( return (
<div className={styles.actionRow}> <div className={styles.actionRow}>
@ -44,6 +50,16 @@ export const ActionRow: FC<Props> = ({
<RadioButtonGroup options={layoutOptions} onChange={onLayoutChange} value={query.layout} /> <RadioButtonGroup options={layoutOptions} onChange={onLayoutChange} value={query.layout} />
) : null} ) : null}
<SortPicker onChange={onSortChange} value={query.sort?.value} /> <SortPicker onChange={onSortChange} value={query.sort?.value} />
{previewsEnabled && (
<InlineSwitch
id="search-show-previews"
label="Show previews"
showLabel
value={showPreviews}
onChange={onShowPreviewsChange}
transparent
/>
)}
</HorizontalGroup> </HorizontalGroup>
</div> </div>
<HorizontalGroup spacing="md" width="auto"> <HorizontalGroup spacing="md" width="auto">

View File

@ -1,12 +1,15 @@
import React, { FC, memo } from 'react'; import React, { FC, memo } from 'react';
import { useLocalStorage } from 'react-use';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { useTheme2, CustomScrollbar, stylesFactory, IconButton } from '@grafana/ui'; import { useTheme2, CustomScrollbar, stylesFactory, IconButton } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { useSearchQuery } from '../hooks/useSearchQuery'; import { useSearchQuery } from '../hooks/useSearchQuery';
import { useDashboardSearch } from '../hooks/useDashboardSearch'; import { useDashboardSearch } from '../hooks/useDashboardSearch';
import { SearchField } from './SearchField'; import { SearchField } from './SearchField';
import { SearchResults } from './SearchResults'; import { SearchResults } from './SearchResults';
import { ActionRow } from './ActionRow'; import { ActionRow } from './ActionRow';
import { PREVIEWS_LOCAL_STORAGE_KEY } from '../constants';
export interface Props { export interface Props {
onCloseSearch: () => void; onCloseSearch: () => void;
@ -17,6 +20,11 @@ export const DashboardSearch: FC<Props> = memo(({ onCloseSearch }) => {
const { results, loading, onToggleSection, onKeyDown } = useDashboardSearch(query, onCloseSearch); const { results, loading, onToggleSection, onKeyDown } = useDashboardSearch(query, onCloseSearch);
const theme = useTheme2(); const theme = useTheme2();
const styles = getStyles(theme); const styles = getStyles(theme);
const previewsEnabled = config.featureToggles.dashboardPreviews;
const [showPreviews, setShowPreviews] = useLocalStorage<boolean>(PREVIEWS_LOCAL_STORAGE_KEY, previewsEnabled);
const onShowPreviewsChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setShowPreviews(event.target.checked);
};
return ( return (
<div tabIndex={0} className={styles.overlay}> <div tabIndex={0} className={styles.overlay}>
@ -31,9 +39,11 @@ export const DashboardSearch: FC<Props> = memo(({ onCloseSearch }) => {
<ActionRow <ActionRow
{...{ {...{
onLayoutChange, onLayoutChange,
onShowPreviewsChange,
onSortChange, onSortChange,
onTagFilterChange, onTagFilterChange,
query, query,
showPreviews,
}} }}
/> />
<CustomScrollbar> <CustomScrollbar>
@ -44,6 +54,7 @@ export const DashboardSearch: FC<Props> = memo(({ onCloseSearch }) => {
editable={false} editable={false}
onToggleSection={onToggleSection} onToggleSection={onToggleSection}
layout={query.layout} layout={query.layout}
showPreviews={showPreviews}
/> />
</CustomScrollbar> </CustomScrollbar>
</div> </div>

View File

@ -1,4 +1,5 @@
import React, { FC, memo, useState } from 'react'; import React, { FC, memo, useState } from 'react';
import { useLocalStorage } from 'react-use';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { stylesFactory, useTheme, Spinner, FilterInput } from '@grafana/ui'; import { stylesFactory, useTheme, Spinner, FilterInput } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data'; import { GrafanaTheme } from '@grafana/data';
@ -13,6 +14,7 @@ import { useSearchQuery } from '../hooks/useSearchQuery';
import { SearchResultsFilter } from './SearchResultsFilter'; import { SearchResultsFilter } from './SearchResultsFilter';
import { SearchResults } from './SearchResults'; import { SearchResults } from './SearchResults';
import { DashboardActions } from './DashboardActions'; import { DashboardActions } from './DashboardActions';
import { PREVIEWS_LOCAL_STORAGE_KEY } from '../constants';
export interface Props { export interface Props {
folder?: FolderDTO; folder?: FolderDTO;
@ -21,6 +23,10 @@ export interface Props {
const { isEditor } = contextSrv; const { isEditor } = contextSrv;
export const ManageDashboards: FC<Props> = memo(({ folder }) => { export const ManageDashboards: FC<Props> = memo(({ folder }) => {
const [showPreviews, setShowPreviews] = useLocalStorage<boolean>(PREVIEWS_LOCAL_STORAGE_KEY, true);
const onShowPreviewsChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setShowPreviews(event.target.checked);
};
const folderId = folder?.id; const folderId = folder?.id;
const folderUid = folder?.uid; const folderUid = folder?.uid;
const theme = useTheme(); const theme = useTheme();
@ -106,11 +112,13 @@ export const ManageDashboards: FC<Props> = memo(({ folder }) => {
canMove={hasEditPermissionInFolders && canMove} canMove={hasEditPermissionInFolders && canMove}
deleteItem={onItemDelete} deleteItem={onItemDelete}
moveTo={onMoveTo} moveTo={onMoveTo}
onShowPreviewsChange={onShowPreviewsChange}
onToggleAllChecked={onToggleAllChecked} onToggleAllChecked={onToggleAllChecked}
onStarredFilterChange={onStarredFilterChange} onStarredFilterChange={onStarredFilterChange}
onSortChange={onSortChange} onSortChange={onSortChange}
onTagFilterChange={onTagFilterChange} onTagFilterChange={onTagFilterChange}
query={query} query={query}
showPreviews={showPreviews}
hideLayout={!!folderUid} hideLayout={!!folderUid}
onLayoutChange={onLayoutChange} onLayoutChange={onLayoutChange}
editable={hasEditPermissionInFolders} editable={hasEditPermissionInFolders}
@ -123,6 +131,7 @@ export const ManageDashboards: FC<Props> = memo(({ folder }) => {
onToggleSection={onToggleSection} onToggleSection={onToggleSection}
onToggleChecked={onToggleChecked} onToggleChecked={onToggleChecked}
layout={query.layout} layout={query.layout}
showPreviews={showPreviews}
/> />
</div> </div>
<ConfirmDeleteModal <ConfirmDeleteModal

View File

@ -0,0 +1,238 @@
import React, { useCallback, useRef, useState } from 'react';
import { css } from '@emotion/css';
import { usePopper } from 'react-popper';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, Portal, TagList, useTheme2 } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
import { backendSrv } from 'app/core/services/backend_srv';
import { DashboardSectionItem, OnToggleChecked } from '../types';
import { SearchCheckbox } from './SearchCheckbox';
import { SearchCardExpanded } from './SearchCardExpanded';
const DELAY_BEFORE_EXPANDING = 500;
export interface Props {
editable?: boolean;
item: DashboardSectionItem;
onTagSelected?: (name: string) => 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<string>();
const [showExpandedView, setShowExpandedView] = useState(false);
const timeout = useRef<number | null>(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<HTMLDivElement | null>(null);
const [popperElement, setPopperElement] = React.useState<HTMLDivElement | null>(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 (
<a
data-testid={selectors.components.Search.dashboardCard(item.title)}
className={styles.card}
key={item.uid}
href={item.url}
ref={(ref) => setMarkerElement((ref as unknown) as HTMLDivElement)}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onMouseMove={onMouseMove}
>
<div className={styles.imageContainer}>
<SearchCheckbox
className={styles.checkbox}
aria-label="Select dashboard"
editable={editable}
checked={item.checked}
onClick={onCheckboxClick}
/>
{hasImage ? (
<img loading="lazy" className={styles.image} src={imageSrc} onError={() => setHasImage(false)} />
) : (
<div className={styles.imagePlaceholder}>
<Icon name="apps" size="xl" />
</div>
)}
</div>
<div className={styles.info}>
<div className={styles.title}>{item.title}</div>
<TagList displayMax={1} tags={item.tags} onClick={onTagClick} />
</div>
{showExpandedView && (
<Portal className={styles.portal}>
<div ref={setPopperElement} style={popperStyles.popper} {...attributes.popper}>
<SearchCardExpanded
className={styles.expandedView}
imageHeight={240}
imageWidth={320}
item={item}
lastUpdated={lastUpdated}
/>
</div>
</Portal>
)}
</a>
);
}
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;
`,
};
};

View File

@ -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 (
<a className={classNames(className, styles.card)} key={item.uid} href={item.url}>
<div className={styles.imageContainer}>
{hasImage ? (
<img
loading="lazy"
className={styles.image}
src={imageSrc}
onLoad={() => setHasImage(true)}
onError={() => setHasImage(false)}
/>
) : (
<div className={styles.imagePlaceholder}>
<Icon name="apps" size="xl" />
</div>
)}
</div>
<div className={styles.info}>
<div className={styles.infoHeader}>
<div className={styles.titleContainer}>
<div>{item.title}</div>
<div className={styles.folder}>
<Icon name={'folder'} />
{folderTitle}
</div>
</div>
<div className={styles.updateContainer}>
<div>Last updated</div>
{lastUpdated ? <div className={styles.update}>{lastUpdated}</div> : <Spinner />}
</div>
</div>
<div>
<TagList className={styles.tagList} tags={item.tags} />
</div>
</div>
</a>
);
}
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;
`,
};
};

View File

@ -32,10 +32,28 @@ describe('SearchResults', () => {
it('should render section items for expanded section', () => { it('should render section items for expanded section', () => {
setup(); setup();
expect(screen.getAllByTestId(selectors.components.Search.collapseFolder('0'))).toHaveLength(1); expect(screen.getByTestId(selectors.components.Search.collapseFolder('0'))).toBeInTheDocument();
expect(screen.getAllByTestId(selectors.components.Search.itemsV2)).toHaveLength(1); expect(screen.getByTestId(selectors.components.Search.itemsV2)).toBeInTheDocument();
expect(screen.getAllByTestId(selectors.components.Search.dashboardItem('Test 1'))).toHaveLength(1); expect(screen.getByTestId(selectors.components.Search.dashboardItem('Test 1'))).toBeInTheDocument();
expect(screen.getAllByTestId(selectors.components.Search.dashboardItem('Test 2'))).toHaveLength(1); 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', () => { it('should not render checkboxes for non-editable results', () => {

View File

@ -1,6 +1,7 @@
import React, { FC, memo } from 'react'; import React, { FC, memo } from 'react';
import { css } from '@emotion/css'; 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 AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme } from '@grafana/data'; import { GrafanaTheme } from '@grafana/data';
import { Spinner, stylesFactory, useTheme } from '@grafana/ui'; import { Spinner, stylesFactory, useTheme } from '@grafana/ui';
@ -8,6 +9,7 @@ import { selectors } from '@grafana/e2e-selectors';
import { DashboardSection, OnToggleChecked, SearchLayout } from '../types'; import { DashboardSection, OnToggleChecked, SearchLayout } from '../types';
import { SEARCH_ITEM_HEIGHT, SEARCH_ITEM_MARGIN } from '../constants'; import { SEARCH_ITEM_HEIGHT, SEARCH_ITEM_MARGIN } from '../constants';
import { SearchItem } from './SearchItem'; import { SearchItem } from './SearchItem';
import { SearchCard } from './SearchCard';
import { SectionHeader } from './SectionHeader'; import { SectionHeader } from './SectionHeader';
export interface Props { export interface Props {
@ -17,13 +19,14 @@ export interface Props {
onToggleChecked?: OnToggleChecked; onToggleChecked?: OnToggleChecked;
onToggleSection: (section: DashboardSection) => void; onToggleSection: (section: DashboardSection) => void;
results: DashboardSection[]; results: DashboardSection[];
showPreviews?: boolean;
layout?: string; layout?: string;
} }
const { sectionV2: sectionLabel, itemsV2: itemsLabel } = selectors.components.Search; const { sectionV2: sectionLabel, itemsV2: itemsLabel, cards: cardsLabel } = selectors.components.Search;
export const SearchResults: FC<Props> = memo( export const SearchResults: FC<Props> = memo(
({ editable, loading, onTagSelected, onToggleChecked, onToggleSection, results, layout }) => { ({ editable, loading, onTagSelected, onToggleChecked, onToggleSection, results, showPreviews, layout }) => {
const theme = useTheme(); const theme = useTheme();
const styles = getSectionStyles(theme); const styles = getSectionStyles(theme);
const itemProps = { editable, onToggleChecked, onTagSelected }; const itemProps = { editable, onToggleChecked, onTagSelected };
@ -36,13 +39,20 @@ export const SearchResults: FC<Props> = memo(
{section.title && ( {section.title && (
<SectionHeader onSectionClick={onToggleSection} {...{ onToggleChecked, editable, section }} /> <SectionHeader onSectionClick={onToggleSection} {...{ onToggleChecked, editable, section }} />
)} )}
{section.expanded && ( {section.expanded &&
(showPreviews ? (
<div data-testid={cardsLabel} className={classNames(styles.sectionItems, styles.gridContainer)}>
{section.items.map((item) => (
<SearchCard {...itemProps} key={item.uid} item={item} />
))}
</div>
) : (
<div data-testid={itemsLabel} className={styles.sectionItems}> <div data-testid={itemsLabel} className={styles.sectionItems}>
{section.items.map((item) => ( {section.items.map((item) => (
<SearchItem key={item.id} {...itemProps} item={item} /> <SearchItem key={item.id} {...itemProps} item={item} />
))} ))}
</div> </div>
)} ))}
</div> </div>
); );
})} })}
@ -53,15 +63,43 @@ export const SearchResults: FC<Props> = memo(
const items = results[0]?.items; const items = results[0]?.items;
return ( return (
<div className={styles.listModeWrapper}> <div className={styles.listModeWrapper}>
<AutoSizer disableWidth> <AutoSizer>
{({ height }) => ( {({ 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 ? (
<FixedSizeGrid
columnCount={numColumns}
columnWidth={cellWidth}
rowCount={numRows}
rowHeight={cellHeight}
className={styles.wrapper}
innerElementType="ul"
height={height}
width={width}
>
{({ 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 ? (
<li style={style} className={styles.virtualizedGridItemWrapper}>
<SearchCard key={item.id} {...itemProps} item={item} />
</li>
) : null;
}}
</FixedSizeGrid>
) : (
<FixedSizeList <FixedSizeList
className={styles.wrapper} className={styles.wrapper}
innerElementType="ul" innerElementType="ul"
itemSize={SEARCH_ITEM_HEIGHT + SEARCH_ITEM_MARGIN} itemSize={SEARCH_ITEM_HEIGHT + SEARCH_ITEM_MARGIN}
height={height} height={height}
itemCount={items.length} itemCount={items.length}
width="100%" width={width}
> >
{({ index, style }) => { {({ index, style }) => {
const item = items[index]; const item = items[index];
@ -74,7 +112,8 @@ export const SearchResults: FC<Props> = memo(
); );
}} }}
</FixedSizeList> </FixedSizeList>
)} );
}}
</AutoSizer> </AutoSizer>
</div> </div>
); );
@ -97,12 +136,19 @@ export const SearchResults: FC<Props> = memo(
SearchResults.displayName = 'SearchResults'; SearchResults.displayName = 'SearchResults';
const getSectionStyles = stylesFactory((theme: GrafanaTheme) => { const getSectionStyles = stylesFactory((theme: GrafanaTheme) => {
const { md } = theme.spacing; const { md, sm } = theme.spacing;
return { return {
virtualizedGridItemWrapper: css`
padding: 4px;
`,
wrapper: css` wrapper: css`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
> ul {
list-style: none;
}
`, `,
section: css` section: css`
display: flex; display: flex;
@ -119,6 +165,12 @@ const getSectionStyles = stylesFactory((theme: GrafanaTheme) => {
align-items: center; align-items: center;
min-height: 100px; min-height: 100px;
`, `,
gridContainer: css`
display: grid;
gap: ${sm};
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
margin-bottom: ${md};
`,
resultsContainer: css` resultsContainer: css`
position: relative; position: relative;
flex-grow: 10; flex-grow: 10;

View File

@ -37,6 +37,7 @@ const setup = (propOverrides?: Partial<Props>) => {
onLayoutChange: noop, onLayoutChange: noop,
query: searchQuery, query: searchQuery,
onSortChange: noop, onSortChange: noop,
onShowPreviewsChange: noop,
editable: true, editable: true,
}; };

View File

@ -1,4 +1,4 @@
import React, { FC, FormEvent } from 'react'; import React, { FC, ChangeEvent, FormEvent } from 'react';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { Button, Checkbox, stylesFactory, useTheme, HorizontalGroup } from '@grafana/ui'; import { Button, Checkbox, stylesFactory, useTheme, HorizontalGroup } from '@grafana/ui';
import { GrafanaTheme, SelectableValue } from '@grafana/data'; import { GrafanaTheme, SelectableValue } from '@grafana/data';
@ -13,11 +13,13 @@ export interface Props {
hideLayout?: boolean; hideLayout?: boolean;
moveTo: () => void; moveTo: () => void;
onLayoutChange: (layout: SearchLayout) => void; onLayoutChange: (layout: SearchLayout) => void;
onShowPreviewsChange: (event: ChangeEvent<HTMLInputElement>) => void;
onSortChange: (value: SelectableValue) => void; onSortChange: (value: SelectableValue) => void;
onStarredFilterChange: (event: FormEvent<HTMLInputElement>) => void; onStarredFilterChange: (event: FormEvent<HTMLInputElement>) => void;
onTagFilterChange: (tags: string[]) => void; onTagFilterChange: (tags: string[]) => void;
onToggleAllChecked: () => void; onToggleAllChecked: () => void;
query: DashboardQuery; query: DashboardQuery;
showPreviews?: boolean;
editable?: boolean; editable?: boolean;
} }
@ -29,11 +31,13 @@ export const SearchResultsFilter: FC<Props> = ({
hideLayout, hideLayout,
moveTo, moveTo,
onLayoutChange, onLayoutChange,
onShowPreviewsChange,
onSortChange, onSortChange,
onStarredFilterChange, onStarredFilterChange,
onTagFilterChange, onTagFilterChange,
onToggleAllChecked, onToggleAllChecked,
query, query,
showPreviews,
editable, editable,
}) => { }) => {
const showActions = canDelete || canMove; const showActions = canDelete || canMove;
@ -61,10 +65,12 @@ export const SearchResultsFilter: FC<Props> = ({
{...{ {...{
hideLayout, hideLayout,
onLayoutChange, onLayoutChange,
onShowPreviewsChange,
onSortChange, onSortChange,
onStarredFilterChange, onStarredFilterChange,
onTagFilterChange, onTagFilterChange,
query, query,
showPreviews,
}} }}
showStarredFilter showStarredFilter
/> />

View File

@ -6,3 +6,4 @@ export const DEFAULT_SORT = { label: 'A\u2013Z', value: 'alpha-asc' };
export const SECTION_STORAGE_KEY = 'search.sections'; export const SECTION_STORAGE_KEY = 'search.sections';
export const GENERAL_FOLDER_ID = 0; export const GENERAL_FOLDER_ID = 0;
export const GENERAL_FOLDER_TITLE = 'General'; export const GENERAL_FOLDER_TITLE = 'General';
export const PREVIEWS_LOCAL_STORAGE_KEY = 'grafana.dashboard.previews';

View File

@ -11,7 +11,8 @@ import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import impressionSrv from 'app/core/services/impression_srv'; import impressionSrv from 'app/core/services/impression_srv';
import { DashboardSearchHit } from 'app/features/search/types'; import { DashboardSearchHit } from 'app/features/search/types';
import { getStyles } from './styles'; 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 }; type Dashboard = DashboardSearchHit & { isSearchResult?: boolean; isRecent?: boolean };
@ -105,7 +106,7 @@ export function DashList(props: PanelProps<PanelOptions>) {
]; ];
}, [dashboards]); }, [dashboards]);
const { showStarred, showRecentlyViewed, showHeadings, showSearch } = props.options; const { showStarred, showRecentlyViewed, showHeadings, showSearch, layout } = props.options;
const dashboardGroups: DashboardGroup[] = [ const dashboardGroups: DashboardGroup[] = [
{ {
@ -152,6 +153,16 @@ export function DashList(props: PanelProps<PanelOptions>) {
</ul> </ul>
); );
const renderPreviews = (dashboards: Dashboard[]) => (
<ul className={css.gridContainer}>
{dashboards.map((dash) => (
<li key={dash.uid}>
<SearchCard item={dash} />
</li>
))}
</ul>
);
return ( return (
<CustomScrollbar autoHeightMin="100%" autoHeightMax="100%"> <CustomScrollbar autoHeightMin="100%" autoHeightMax="100%">
{dashboardGroups.map( {dashboardGroups.map(
@ -159,7 +170,7 @@ export function DashList(props: PanelProps<PanelOptions>) {
show && ( show && (
<div className={css.dashlistSection} key={`dash-group-${i}`}> <div className={css.dashlistSection} key={`dash-group-${i}`}>
{showHeadings && <h6 className={css.dashlistSectionHeader}>{header}</h6>} {showHeadings && <h6 className={css.dashlistSectionHeader}>{header}</h6>}
{renderList(dashboards)} {layout === PanelLayout.Previews ? renderPreviews(dashboards) : renderList(dashboards)}
</div> </div>
) )
)} )}

View File

@ -19,6 +19,7 @@ Panel: {
[ [
{ {
PanelOptions: { PanelOptions: {
layout?: *"list" | "previews"
showStarred: bool | *true showStarred: bool | *true
showRecentlyViewed: bool | *false showRecentlyViewed: bool | *false
showSearch: bool | *false showSearch: bool | *false

View File

@ -4,7 +4,13 @@
export const modelVersion = Object.freeze([0, 0]); export const modelVersion = Object.freeze([0, 0]);
export enum PanelLayout {
List = 'list',
Previews = 'previews',
}
export interface PanelOptions { export interface PanelOptions {
layout?: PanelLayout;
folderId?: number; folderId?: number;
maxItems: number; maxItems: number;
query: string; query: string;
@ -16,6 +22,7 @@ export interface PanelOptions {
} }
export const defaultPanelOptions: PanelOptions = { export const defaultPanelOptions: PanelOptions = {
layout: PanelLayout.List,
maxItems: 10, maxItems: 10,
query: '', query: '',
showHeadings: true, showHeadings: true,

View File

@ -7,10 +7,25 @@ import {
GENERAL_FOLDER, GENERAL_FOLDER,
ReadonlyFolderPicker, ReadonlyFolderPicker,
} from '../../../core/components/Select/ReadonlyFolderPicker/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<PanelOptions>(DashList) export const plugin = new PanelPlugin<PanelOptions>(DashList)
.setPanelOptions((builder) => { .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 builder
.addBooleanSwitch({ .addBooleanSwitch({
path: 'showStarred', path: 'showStarred',

View File

@ -54,4 +54,12 @@ export const getStyles = (theme: GrafanaTheme2) => ({
position: relative; position: relative;
list-style: none; 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)};
`,
}); });