mirror of
https://github.com/grafana/grafana.git
synced 2024-11-26 02:40:26 -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/datasources @grafana/plugins-platform-backend
|
||||
|
||||
# Dashboard previews / crawler (behind feature flag)
|
||||
/pkg/services/thumbs @grafana/grafana-edge-squad
|
||||
|
||||
# Backend code docs
|
||||
/contribute/style-guides/backend.md @grafana/backend-platform
|
||||
/contribute/architecture/backend @grafana/backend-platform
|
||||
|
@ -52,6 +52,7 @@ export interface FeatureToggles {
|
||||
recordedQueries: boolean;
|
||||
newNavigation: boolean;
|
||||
fullRangeLogsVolume: boolean;
|
||||
dashboardPreviews: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -274,9 +274,11 @@ export const Components = {
|
||||
*/
|
||||
items: 'Search items',
|
||||
itemsV2: 'data-testid Search items',
|
||||
cards: 'data-testid Search cards',
|
||||
collapseFolder: (sectionId: string) => `data-testid Collapse folder ${sectionId}`,
|
||||
expandFolder: (sectionId: string) => `data-testid Expand folder ${sectionId}`,
|
||||
dashboardItem: (item: string) => `${Components.Search.dashboardItems} ${item}`,
|
||||
dashboardCard: (item: string) => `data-testid Search card ${item}`,
|
||||
dashboardItems: 'data-testid Dashboard search item',
|
||||
},
|
||||
DashboardLinks: {
|
||||
|
@ -69,6 +69,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
|
||||
recordedQueries: false,
|
||||
newNavigation: false,
|
||||
fullRangeLogsVolume: false,
|
||||
dashboardPreviews: false,
|
||||
};
|
||||
licenseInfo: LicenseInfo = {} as LicenseInfo;
|
||||
rendererAvailable = false;
|
||||
|
@ -1,39 +1,49 @@
|
||||
import React, { FC, memo } from 'react';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { OnTagClick, Tag } from './Tag';
|
||||
import { useTheme2 } from '../../themes';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
export interface Props {
|
||||
displayMax?: number;
|
||||
tags: string[];
|
||||
onClick?: OnTagClick;
|
||||
/** Custom styles for the wrapper component */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const TagList: FC<Props> = memo(({ tags, onClick, className }) => {
|
||||
const styles = getStyles();
|
||||
export const TagList: FC<Props> = memo(({ displayMax, tags, onClick, className }) => {
|
||||
const theme = useTheme2();
|
||||
const styles = getStyles(theme, Boolean(displayMax && displayMax > 0));
|
||||
const numTags = tags.length;
|
||||
const tagsToDisplay = displayMax ? tags.slice(0, displayMax) : tags;
|
||||
|
||||
return (
|
||||
<span className={cx(styles.wrapper, className)}>
|
||||
{tags.map((tag) => (
|
||||
<Tag key={tag} name={tag} onClick={onClick} className={styles.tag} />
|
||||
{tagsToDisplay.map((tag) => (
|
||||
<Tag key={tag} name={tag} onClick={onClick} />
|
||||
))}
|
||||
{displayMax && displayMax > 0 && numTags - 1 > 0 && <span className={styles.moreTagsLabel}>+ {numTags - 1}</span>}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
TagList.displayName = 'TagList';
|
||||
|
||||
const getStyles = () => {
|
||||
const getStyles = (theme: GrafanaTheme2, isTruncated: boolean) => {
|
||||
return {
|
||||
wrapper: css`
|
||||
align-items: ${isTruncated ? 'center' : 'unset'};
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: -6px;
|
||||
flex-shrink: ${isTruncated ? 0 : 1};
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
`,
|
||||
tag: css`
|
||||
margin: 0 0 6px 6px;
|
||||
moreTagsLabel: css`
|
||||
color: ${theme.colors.text.secondary};
|
||||
font-size: ${theme.typography.size.sm};
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
@ -327,6 +327,11 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
dashboardRoute.Get("/uid/:uid", routing.Wrap(hs.GetDashboard))
|
||||
dashboardRoute.Delete("/uid/:uid", routing.Wrap(hs.DeleteDashboardByUID))
|
||||
|
||||
if hs.ThumbService != nil {
|
||||
dashboardRoute.Get("/uid/:uid/img/:size/:theme", hs.ThumbService.GetImage)
|
||||
dashboardRoute.Post("/uid/:uid/img/:size/:theme", hs.ThumbService.SetImage)
|
||||
}
|
||||
|
||||
dashboardRoute.Post("/calculate-diff", routing.Wrap(CalculateDashboardDiff))
|
||||
dashboardRoute.Post("/trim", routing.Wrap(hs.TrimDashboard))
|
||||
|
||||
|
@ -14,6 +14,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/query"
|
||||
"github.com/grafana/grafana/pkg/services/thumbs"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/routing"
|
||||
httpstatic "github.com/grafana/grafana/pkg/api/static"
|
||||
@ -94,6 +95,7 @@ type HTTPServer struct {
|
||||
ShortURLService shorturls.Service
|
||||
Live *live.GrafanaLive
|
||||
LivePushGateway *pushhttp.Gateway
|
||||
ThumbService thumbs.Service
|
||||
ContextHandler *contexthandler.ContextHandler
|
||||
SQLStore *sqlstore.SQLStore
|
||||
AlertEngine *alerting.AlertEngine
|
||||
@ -124,7 +126,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
|
||||
pluginDashboardManager plugins.PluginDashboardManager, pluginStore plugins.Store, pluginClient plugins.Client,
|
||||
pluginErrorResolver plugins.ErrorResolver, settingsProvider setting.Provider,
|
||||
dataSourceCache datasources.CacheService, userTokenService models.UserTokenService,
|
||||
cleanUpService *cleanup.CleanUpService, shortURLService shorturls.Service,
|
||||
cleanUpService *cleanup.CleanUpService, shortURLService shorturls.Service, thumbService thumbs.Service,
|
||||
remoteCache *remotecache.RemoteCache, provisioningService provisioning.ProvisioningService,
|
||||
loginService login.Service, accessControl accesscontrol.AccessControl,
|
||||
dataSourceProxy *datasourceproxy.DataSourceProxyService, searchService *search.SearchService,
|
||||
@ -161,6 +163,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
|
||||
AuthTokenService: userTokenService,
|
||||
cleanUpService: cleanUpService,
|
||||
ShortURLService: shortURLService,
|
||||
ThumbService: thumbService,
|
||||
RemoteCacheService: remoteCache,
|
||||
ProvisioningService: provisioningService,
|
||||
Login: loginService,
|
||||
|
@ -59,6 +59,7 @@ import (
|
||||
serviceaccountsmanager "github.com/grafana/grafana/pkg/services/serviceaccounts/manager"
|
||||
"github.com/grafana/grafana/pkg/services/shorturls"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/services/thumbs"
|
||||
"github.com/grafana/grafana/pkg/services/updatechecker"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/tsdb/azuremonitor"
|
||||
@ -91,6 +92,7 @@ var wireBasicSet = wire.NewSet(
|
||||
query.ProvideService,
|
||||
bus.ProvideBus,
|
||||
wire.Bind(new(bus.Bus), new(*bus.InProcBus)),
|
||||
thumbs.ProvideService,
|
||||
rendering.ProvideService,
|
||||
wire.Bind(new(rendering.Service), new(*rendering.RenderingService)),
|
||||
routing.ProvideRegister,
|
||||
|
@ -56,9 +56,12 @@ func (*OSSMigrations) AddMigration(mg *Migrator) {
|
||||
ualert.AddTablesMigrations(mg)
|
||||
ualert.AddDashAlertMigration(mg)
|
||||
addLibraryElementsMigrations(mg)
|
||||
if mg.Cfg != nil || mg.Cfg.IsLiveConfigEnabled() {
|
||||
if mg.Cfg != nil {
|
||||
if mg.Cfg.IsLiveConfigEnabled() {
|
||||
addLiveChannelMigrations(mg)
|
||||
}
|
||||
}
|
||||
|
||||
ualert.RerunDashAlertMigration(mg)
|
||||
addSecretsMigration(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"]
|
||||
}
|
||||
|
||||
// IsLiveConfigEnabled returns true if live should be able to save configs to SQL tables
|
||||
func (cfg Cfg) IsDashboardPreviesEnabled() bool {
|
||||
return cfg.FeatureToggles["dashboardPreviews"]
|
||||
}
|
||||
|
||||
// IsTrimDefaultsEnabled returns whether the standalone trim dashboard default feature is enabled.
|
||||
func (cfg Cfg) IsTrimDefaultsEnabled() bool {
|
||||
return cfg.FeatureToggles["trimDefaults"]
|
||||
|
@ -9,6 +9,8 @@ import { DeleteDashboardButton } from '../DeleteDashboard/DeleteDashboardButton'
|
||||
import { TimePickerSettings } from './TimePickerSettings';
|
||||
|
||||
import { updateTimeZoneDashboard, updateWeekStartDashboard } from 'app/features/dashboard/state/actions';
|
||||
import { PreviewSettings } from './PreviewSettings';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
interface OwnProps {
|
||||
dashboard: DashboardModel;
|
||||
@ -120,6 +122,8 @@ export function GeneralSettingsUnconnected({ dashboard, updateTimeZone, updateWe
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{config.featureToggles.dashboardPreviews && <PreviewSettings uid={dashboard.uid} />}
|
||||
|
||||
<TimePickerSettings
|
||||
onTimeZoneChange={onTimeZoneChange}
|
||||
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 { HorizontalGroup, RadioButtonGroup, stylesFactory, useTheme, Checkbox } from '@grafana/ui';
|
||||
import { HorizontalGroup, RadioButtonGroup, stylesFactory, useTheme, Checkbox, InlineSwitch } from '@grafana/ui';
|
||||
import { GrafanaTheme, SelectableValue } from '@grafana/data';
|
||||
import { SortPicker } from 'app/core/components/Select/SortPicker';
|
||||
import { TagFilter } from 'app/core/components/TagFilter/TagFilter';
|
||||
import { SearchSrv } from 'app/core/services/search_srv';
|
||||
import { DashboardQuery, SearchLayout } from '../types';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
export const layoutOptions = [
|
||||
{ value: SearchLayout.Folders, icon: 'folder', ariaLabel: 'View by folders' },
|
||||
@ -16,25 +17,30 @@ const searchSrv = new SearchSrv();
|
||||
|
||||
interface Props {
|
||||
onLayoutChange: (layout: SearchLayout) => void;
|
||||
onShowPreviewsChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onSortChange: (value: SelectableValue) => void;
|
||||
onStarredFilterChange?: (event: FormEvent<HTMLInputElement>) => void;
|
||||
onTagFilterChange: (tags: string[]) => void;
|
||||
query: DashboardQuery;
|
||||
showStarredFilter?: boolean;
|
||||
hideLayout?: boolean;
|
||||
showPreviews?: boolean;
|
||||
}
|
||||
|
||||
export const ActionRow: FC<Props> = ({
|
||||
onLayoutChange,
|
||||
onShowPreviewsChange,
|
||||
onSortChange,
|
||||
onStarredFilterChange = () => {},
|
||||
onTagFilterChange,
|
||||
query,
|
||||
showStarredFilter,
|
||||
hideLayout,
|
||||
showPreviews,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const styles = getStyles(theme);
|
||||
const previewsEnabled = config.featureToggles.dashboardPreviews;
|
||||
|
||||
return (
|
||||
<div className={styles.actionRow}>
|
||||
@ -44,6 +50,16 @@ export const ActionRow: FC<Props> = ({
|
||||
<RadioButtonGroup options={layoutOptions} onChange={onLayoutChange} value={query.layout} />
|
||||
) : null}
|
||||
<SortPicker onChange={onSortChange} value={query.sort?.value} />
|
||||
{previewsEnabled && (
|
||||
<InlineSwitch
|
||||
id="search-show-previews"
|
||||
label="Show previews"
|
||||
showLabel
|
||||
value={showPreviews}
|
||||
onChange={onShowPreviewsChange}
|
||||
transparent
|
||||
/>
|
||||
)}
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<HorizontalGroup spacing="md" width="auto">
|
||||
|
@ -1,12 +1,15 @@
|
||||
import React, { FC, memo } from 'react';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
import { css } from '@emotion/css';
|
||||
import { useTheme2, CustomScrollbar, stylesFactory, IconButton } from '@grafana/ui';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { useSearchQuery } from '../hooks/useSearchQuery';
|
||||
import { useDashboardSearch } from '../hooks/useDashboardSearch';
|
||||
import { SearchField } from './SearchField';
|
||||
import { SearchResults } from './SearchResults';
|
||||
import { ActionRow } from './ActionRow';
|
||||
import { PREVIEWS_LOCAL_STORAGE_KEY } from '../constants';
|
||||
|
||||
export interface Props {
|
||||
onCloseSearch: () => void;
|
||||
@ -17,6 +20,11 @@ export const DashboardSearch: FC<Props> = memo(({ onCloseSearch }) => {
|
||||
const { results, loading, onToggleSection, onKeyDown } = useDashboardSearch(query, onCloseSearch);
|
||||
const theme = useTheme2();
|
||||
const styles = getStyles(theme);
|
||||
const previewsEnabled = config.featureToggles.dashboardPreviews;
|
||||
const [showPreviews, setShowPreviews] = useLocalStorage<boolean>(PREVIEWS_LOCAL_STORAGE_KEY, previewsEnabled);
|
||||
const onShowPreviewsChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setShowPreviews(event.target.checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<div tabIndex={0} className={styles.overlay}>
|
||||
@ -31,9 +39,11 @@ export const DashboardSearch: FC<Props> = memo(({ onCloseSearch }) => {
|
||||
<ActionRow
|
||||
{...{
|
||||
onLayoutChange,
|
||||
onShowPreviewsChange,
|
||||
onSortChange,
|
||||
onTagFilterChange,
|
||||
query,
|
||||
showPreviews,
|
||||
}}
|
||||
/>
|
||||
<CustomScrollbar>
|
||||
@ -44,6 +54,7 @@ export const DashboardSearch: FC<Props> = memo(({ onCloseSearch }) => {
|
||||
editable={false}
|
||||
onToggleSection={onToggleSection}
|
||||
layout={query.layout}
|
||||
showPreviews={showPreviews}
|
||||
/>
|
||||
</CustomScrollbar>
|
||||
</div>
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { FC, memo, useState } from 'react';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
import { css } from '@emotion/css';
|
||||
import { stylesFactory, useTheme, Spinner, FilterInput } from '@grafana/ui';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
@ -13,6 +14,7 @@ import { useSearchQuery } from '../hooks/useSearchQuery';
|
||||
import { SearchResultsFilter } from './SearchResultsFilter';
|
||||
import { SearchResults } from './SearchResults';
|
||||
import { DashboardActions } from './DashboardActions';
|
||||
import { PREVIEWS_LOCAL_STORAGE_KEY } from '../constants';
|
||||
|
||||
export interface Props {
|
||||
folder?: FolderDTO;
|
||||
@ -21,6 +23,10 @@ export interface Props {
|
||||
const { isEditor } = contextSrv;
|
||||
|
||||
export const ManageDashboards: FC<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 folderUid = folder?.uid;
|
||||
const theme = useTheme();
|
||||
@ -106,11 +112,13 @@ export const ManageDashboards: FC<Props> = memo(({ folder }) => {
|
||||
canMove={hasEditPermissionInFolders && canMove}
|
||||
deleteItem={onItemDelete}
|
||||
moveTo={onMoveTo}
|
||||
onShowPreviewsChange={onShowPreviewsChange}
|
||||
onToggleAllChecked={onToggleAllChecked}
|
||||
onStarredFilterChange={onStarredFilterChange}
|
||||
onSortChange={onSortChange}
|
||||
onTagFilterChange={onTagFilterChange}
|
||||
query={query}
|
||||
showPreviews={showPreviews}
|
||||
hideLayout={!!folderUid}
|
||||
onLayoutChange={onLayoutChange}
|
||||
editable={hasEditPermissionInFolders}
|
||||
@ -123,6 +131,7 @@ export const ManageDashboards: FC<Props> = memo(({ folder }) => {
|
||||
onToggleSection={onToggleSection}
|
||||
onToggleChecked={onToggleChecked}
|
||||
layout={query.layout}
|
||||
showPreviews={showPreviews}
|
||||
/>
|
||||
</div>
|
||||
<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', () => {
|
||||
setup();
|
||||
expect(screen.getAllByTestId(selectors.components.Search.collapseFolder('0'))).toHaveLength(1);
|
||||
expect(screen.getAllByTestId(selectors.components.Search.itemsV2)).toHaveLength(1);
|
||||
expect(screen.getAllByTestId(selectors.components.Search.dashboardItem('Test 1'))).toHaveLength(1);
|
||||
expect(screen.getAllByTestId(selectors.components.Search.dashboardItem('Test 2'))).toHaveLength(1);
|
||||
expect(screen.getByTestId(selectors.components.Search.collapseFolder('0'))).toBeInTheDocument();
|
||||
expect(screen.getByTestId(selectors.components.Search.itemsV2)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(selectors.components.Search.dashboardItem('Test 1'))).toBeInTheDocument();
|
||||
expect(screen.getByTestId(selectors.components.Search.dashboardItem('Test 2'))).toBeInTheDocument();
|
||||
|
||||
// Check search cards aren't in the DOM
|
||||
expect(screen.queryByTestId(selectors.components.Search.cards)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(selectors.components.Search.dashboardCard('Test 1'))).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(selectors.components.Search.dashboardCard('Test 2'))).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render search card items for expanded section when showPreviews is enabled', () => {
|
||||
setup({ showPreviews: true });
|
||||
expect(screen.getByTestId(selectors.components.Search.collapseFolder('0'))).toBeInTheDocument();
|
||||
expect(screen.getByTestId(selectors.components.Search.cards)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(selectors.components.Search.dashboardCard('Test 1'))).toBeInTheDocument();
|
||||
expect(screen.getByTestId(selectors.components.Search.dashboardCard('Test 2'))).toBeInTheDocument();
|
||||
|
||||
// Check search items aren't in the DOM
|
||||
expect(screen.queryByTestId(selectors.components.Search.itemsV2)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(selectors.components.Search.dashboardItem('Test 1'))).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(selectors.components.Search.dashboardItem('Test 2'))).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render checkboxes for non-editable results', () => {
|
||||
|
@ -1,6 +1,7 @@
|
||||
import React, { FC, memo } from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import { FixedSizeList } from 'react-window';
|
||||
import classNames from 'classnames';
|
||||
import { FixedSizeList, FixedSizeGrid } from 'react-window';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { Spinner, stylesFactory, useTheme } from '@grafana/ui';
|
||||
@ -8,6 +9,7 @@ import { selectors } from '@grafana/e2e-selectors';
|
||||
import { DashboardSection, OnToggleChecked, SearchLayout } from '../types';
|
||||
import { SEARCH_ITEM_HEIGHT, SEARCH_ITEM_MARGIN } from '../constants';
|
||||
import { SearchItem } from './SearchItem';
|
||||
import { SearchCard } from './SearchCard';
|
||||
import { SectionHeader } from './SectionHeader';
|
||||
|
||||
export interface Props {
|
||||
@ -17,13 +19,14 @@ export interface Props {
|
||||
onToggleChecked?: OnToggleChecked;
|
||||
onToggleSection: (section: DashboardSection) => void;
|
||||
results: DashboardSection[];
|
||||
showPreviews?: boolean;
|
||||
layout?: string;
|
||||
}
|
||||
|
||||
const { sectionV2: sectionLabel, itemsV2: itemsLabel } = selectors.components.Search;
|
||||
const { sectionV2: sectionLabel, itemsV2: itemsLabel, cards: cardsLabel } = selectors.components.Search;
|
||||
|
||||
export const SearchResults: FC<Props> = memo(
|
||||
({ editable, loading, onTagSelected, onToggleChecked, onToggleSection, results, layout }) => {
|
||||
({ editable, loading, onTagSelected, onToggleChecked, onToggleSection, results, showPreviews, layout }) => {
|
||||
const theme = useTheme();
|
||||
const styles = getSectionStyles(theme);
|
||||
const itemProps = { editable, onToggleChecked, onTagSelected };
|
||||
@ -36,13 +39,20 @@ export const SearchResults: FC<Props> = memo(
|
||||
{section.title && (
|
||||
<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}>
|
||||
{section.items.map((item) => (
|
||||
<SearchItem key={item.id} {...itemProps} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -53,15 +63,43 @@ export const SearchResults: FC<Props> = memo(
|
||||
const items = results[0]?.items;
|
||||
return (
|
||||
<div className={styles.listModeWrapper}>
|
||||
<AutoSizer disableWidth>
|
||||
{({ height }) => (
|
||||
<AutoSizer>
|
||||
{({ 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
|
||||
className={styles.wrapper}
|
||||
innerElementType="ul"
|
||||
itemSize={SEARCH_ITEM_HEIGHT + SEARCH_ITEM_MARGIN}
|
||||
height={height}
|
||||
itemCount={items.length}
|
||||
width="100%"
|
||||
width={width}
|
||||
>
|
||||
{({ index, style }) => {
|
||||
const item = items[index];
|
||||
@ -74,7 +112,8 @@ export const SearchResults: FC<Props> = memo(
|
||||
);
|
||||
}}
|
||||
</FixedSizeList>
|
||||
)}
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
);
|
||||
@ -97,12 +136,19 @@ export const SearchResults: FC<Props> = memo(
|
||||
SearchResults.displayName = 'SearchResults';
|
||||
|
||||
const getSectionStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
const { md } = theme.spacing;
|
||||
const { md, sm } = theme.spacing;
|
||||
|
||||
return {
|
||||
virtualizedGridItemWrapper: css`
|
||||
padding: 4px;
|
||||
`,
|
||||
wrapper: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> ul {
|
||||
list-style: none;
|
||||
}
|
||||
`,
|
||||
section: css`
|
||||
display: flex;
|
||||
@ -119,6 +165,12 @@ const getSectionStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
align-items: center;
|
||||
min-height: 100px;
|
||||
`,
|
||||
gridContainer: css`
|
||||
display: grid;
|
||||
gap: ${sm};
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
margin-bottom: ${md};
|
||||
`,
|
||||
resultsContainer: css`
|
||||
position: relative;
|
||||
flex-grow: 10;
|
||||
|
@ -37,6 +37,7 @@ const setup = (propOverrides?: Partial<Props>) => {
|
||||
onLayoutChange: noop,
|
||||
query: searchQuery,
|
||||
onSortChange: noop,
|
||||
onShowPreviewsChange: noop,
|
||||
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 { Button, Checkbox, stylesFactory, useTheme, HorizontalGroup } from '@grafana/ui';
|
||||
import { GrafanaTheme, SelectableValue } from '@grafana/data';
|
||||
@ -13,11 +13,13 @@ export interface Props {
|
||||
hideLayout?: boolean;
|
||||
moveTo: () => void;
|
||||
onLayoutChange: (layout: SearchLayout) => void;
|
||||
onShowPreviewsChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onSortChange: (value: SelectableValue) => void;
|
||||
onStarredFilterChange: (event: FormEvent<HTMLInputElement>) => void;
|
||||
onTagFilterChange: (tags: string[]) => void;
|
||||
onToggleAllChecked: () => void;
|
||||
query: DashboardQuery;
|
||||
showPreviews?: boolean;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
@ -29,11 +31,13 @@ export const SearchResultsFilter: FC<Props> = ({
|
||||
hideLayout,
|
||||
moveTo,
|
||||
onLayoutChange,
|
||||
onShowPreviewsChange,
|
||||
onSortChange,
|
||||
onStarredFilterChange,
|
||||
onTagFilterChange,
|
||||
onToggleAllChecked,
|
||||
query,
|
||||
showPreviews,
|
||||
editable,
|
||||
}) => {
|
||||
const showActions = canDelete || canMove;
|
||||
@ -61,10 +65,12 @@ export const SearchResultsFilter: FC<Props> = ({
|
||||
{...{
|
||||
hideLayout,
|
||||
onLayoutChange,
|
||||
onShowPreviewsChange,
|
||||
onSortChange,
|
||||
onStarredFilterChange,
|
||||
onTagFilterChange,
|
||||
query,
|
||||
showPreviews,
|
||||
}}
|
||||
showStarredFilter
|
||||
/>
|
||||
|
@ -6,3 +6,4 @@ export const DEFAULT_SORT = { label: 'A\u2013Z', value: 'alpha-asc' };
|
||||
export const SECTION_STORAGE_KEY = 'search.sections';
|
||||
export const GENERAL_FOLDER_ID = 0;
|
||||
export const GENERAL_FOLDER_TITLE = 'General';
|
||||
export const PREVIEWS_LOCAL_STORAGE_KEY = 'grafana.dashboard.previews';
|
||||
|
@ -11,7 +11,8 @@ import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||
import impressionSrv from 'app/core/services/impression_srv';
|
||||
import { DashboardSearchHit } from 'app/features/search/types';
|
||||
import { getStyles } from './styles';
|
||||
import { PanelOptions } from './models.gen';
|
||||
import { PanelLayout, PanelOptions } from './models.gen';
|
||||
import { SearchCard } from 'app/features/search/components/SearchCard';
|
||||
|
||||
type Dashboard = DashboardSearchHit & { isSearchResult?: boolean; isRecent?: boolean };
|
||||
|
||||
@ -105,7 +106,7 @@ export function DashList(props: PanelProps<PanelOptions>) {
|
||||
];
|
||||
}, [dashboards]);
|
||||
|
||||
const { showStarred, showRecentlyViewed, showHeadings, showSearch } = props.options;
|
||||
const { showStarred, showRecentlyViewed, showHeadings, showSearch, layout } = props.options;
|
||||
|
||||
const dashboardGroups: DashboardGroup[] = [
|
||||
{
|
||||
@ -152,6 +153,16 @@ export function DashList(props: PanelProps<PanelOptions>) {
|
||||
</ul>
|
||||
);
|
||||
|
||||
const renderPreviews = (dashboards: Dashboard[]) => (
|
||||
<ul className={css.gridContainer}>
|
||||
{dashboards.map((dash) => (
|
||||
<li key={dash.uid}>
|
||||
<SearchCard item={dash} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<CustomScrollbar autoHeightMin="100%" autoHeightMax="100%">
|
||||
{dashboardGroups.map(
|
||||
@ -159,7 +170,7 @@ export function DashList(props: PanelProps<PanelOptions>) {
|
||||
show && (
|
||||
<div className={css.dashlistSection} key={`dash-group-${i}`}>
|
||||
{showHeadings && <h6 className={css.dashlistSectionHeader}>{header}</h6>}
|
||||
{renderList(dashboards)}
|
||||
{layout === PanelLayout.Previews ? renderPreviews(dashboards) : renderList(dashboards)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
@ -19,6 +19,7 @@ Panel: {
|
||||
[
|
||||
{
|
||||
PanelOptions: {
|
||||
layout?: *"list" | "previews"
|
||||
showStarred: bool | *true
|
||||
showRecentlyViewed: bool | *false
|
||||
showSearch: bool | *false
|
||||
|
@ -4,7 +4,13 @@
|
||||
|
||||
export const modelVersion = Object.freeze([0, 0]);
|
||||
|
||||
export enum PanelLayout {
|
||||
List = 'list',
|
||||
Previews = 'previews',
|
||||
}
|
||||
|
||||
export interface PanelOptions {
|
||||
layout?: PanelLayout;
|
||||
folderId?: number;
|
||||
maxItems: number;
|
||||
query: string;
|
||||
@ -16,6 +22,7 @@ export interface PanelOptions {
|
||||
}
|
||||
|
||||
export const defaultPanelOptions: PanelOptions = {
|
||||
layout: PanelLayout.List,
|
||||
maxItems: 10,
|
||||
query: '',
|
||||
showHeadings: true,
|
||||
|
@ -7,10 +7,25 @@ import {
|
||||
GENERAL_FOLDER,
|
||||
ReadonlyFolderPicker,
|
||||
} from '../../../core/components/Select/ReadonlyFolderPicker/ReadonlyFolderPicker';
|
||||
import { defaultPanelOptions, PanelOptions } from './models.gen';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { defaultPanelOptions, PanelLayout, PanelOptions } from './models.gen';
|
||||
|
||||
export const plugin = new PanelPlugin<PanelOptions>(DashList)
|
||||
.setPanelOptions((builder) => {
|
||||
if (config.featureToggles.dashboardPreviews) {
|
||||
builder.addRadio({
|
||||
path: 'layout',
|
||||
name: 'Layout',
|
||||
defaultValue: PanelLayout.List,
|
||||
settings: {
|
||||
options: [
|
||||
{ value: PanelLayout.List, label: 'List' },
|
||||
{ value: PanelLayout.Previews, label: 'Preview' },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
builder
|
||||
.addBooleanSwitch({
|
||||
path: 'showStarred',
|
||||
|
@ -54,4 +54,12 @@ export const getStyles = (theme: GrafanaTheme2) => ({
|
||||
position: relative;
|
||||
list-style: none;
|
||||
`,
|
||||
|
||||
gridContainer: css`
|
||||
display: grid;
|
||||
gap: ${theme.spacing(1)};
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
list-style: none;
|
||||
margin-bottom: ${theme.spacing(1)};
|
||||
`,
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user