mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
545d6c4ddb
commit
4233a62aeb
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@ -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
|
||||||
|
@ -52,6 +52,7 @@ export interface FeatureToggles {
|
|||||||
recordedQueries: boolean;
|
recordedQueries: boolean;
|
||||||
newNavigation: boolean;
|
newNavigation: boolean;
|
||||||
fullRangeLogsVolume: boolean;
|
fullRangeLogsVolume: boolean;
|
||||||
|
dashboardPreviews: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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: {
|
||||||
|
@ -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;
|
||||||
|
@ -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};
|
||||||
`,
|
`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -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))
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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)
|
||||||
|
72
pkg/services/thumbs/crawler_http.go
Normal file
72
pkg/services/thumbs/crawler_http.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
32
pkg/services/thumbs/dummy.go
Normal file
32
pkg/services/thumbs/dummy.go
Normal 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)
|
||||||
|
}
|
103
pkg/services/thumbs/models.go
Normal file
103
pkg/services/thumbs/models.go
Normal 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)
|
||||||
|
}
|
268
pkg/services/thumbs/service.go
Normal file
268
pkg/services/thumbs/service.go
Normal 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
|
||||||
|
}
|
14
pkg/services/thumbs/utils.go
Normal file
14
pkg/services/thumbs/utils.go
Normal 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))
|
||||||
|
}
|
@ -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"]
|
||||||
|
@ -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}
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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">
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
||||||
|
238
public/app/features/search/components/SearchCard.tsx
Normal file
238
public/app/features/search/components/SearchCard.tsx
Normal 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;
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
};
|
153
public/app/features/search/components/SearchCardExpanded.tsx
Normal file
153
public/app/features/search/components/SearchCardExpanded.tsx
Normal 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;
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
};
|
@ -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', () => {
|
||||||
|
@ -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;
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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
|
||||||
/>
|
/>
|
||||||
|
@ -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';
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
@ -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
|
||||||
|
@ -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,
|
||||||
|
@ -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',
|
||||||
|
@ -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)};
|
||||||
|
`,
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user