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

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

3
.github/CODEOWNERS vendored
View File

@ -64,6 +64,9 @@ go.sum @grafana/backend-platform
/pkg/services/datasourceproxy @grafana/plugins-platform-backend
/pkg/services/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

View File

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

View File

@ -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: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,72 @@
package thumbs
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"os"
)
type renderHttp struct {
crawlerURL string
config crawConfig
}
func newRenderHttp(crawlerURL string, cfg crawConfig) dashRenderer {
return &renderHttp{
crawlerURL: crawlerURL,
config: cfg,
}
}
func (r *renderHttp) GetPreview(req *previewRequest) *previewResponse {
p := getFilePath(r.config.ScreenshotsFolder, req)
if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) {
return r.queueRender(p, req)
}
return &previewResponse{
Path: p,
Code: 200,
}
}
func (r *renderHttp) CrawlerCmd(cfg *crawlCmd) (json.RawMessage, error) {
cmd := r.config
cmd.crawlCmd = *cfg
jsonData, err := json.Marshal(cmd)
if err != nil {
return nil, err
}
request, err := http.NewRequest("POST", r.crawlerURL, bytes.NewBuffer(jsonData))
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/json; charset=UTF-8")
client := &http.Client{}
response, error := client.Do(request)
if error != nil {
return nil, err
}
defer func() {
_ = response.Body.Close()
}()
return ioutil.ReadAll(response.Body)
}
func (r *renderHttp) queueRender(p string, req *previewRequest) *previewResponse {
go func() {
fmt.Printf("todo? queue")
}()
return &previewResponse{
Code: 202,
Path: p,
}
}

View File

@ -0,0 +1,32 @@
package thumbs
import (
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/models"
)
// When the feature flag is not enabled we just implement a dummy service
type dummyService struct{}
func (ds *dummyService) GetImage(c *models.ReqContext) {
c.JSON(400, map[string]string{"error": "invalid size"})
}
func (ds *dummyService) SetImage(c *models.ReqContext) {
c.JSON(400, map[string]string{"error": "invalid size"})
}
func (ds *dummyService) Enabled() bool {
return false
}
func (ds *dummyService) StartCrawler(c *models.ReqContext) response.Response {
result := make(map[string]string)
result["error"] = "Not enabled"
return response.JSON(200, result)
}
func (ds *dummyService) StopCrawler(c *models.ReqContext) response.Response {
result := make(map[string]string)
result["error"] = "Not enabled"
return response.JSON(200, result)
}

View File

@ -0,0 +1,103 @@
package thumbs
import "encoding/json"
type PreviewSize string
const (
// PreviewSizeThumb is a small 320x240 preview
PreviewSizeThumb PreviewSize = "thumb"
// PreviewSizeLarge is a large image 2000x1500
PreviewSizeLarge PreviewSize = "large"
// PreviewSizeLarge is a large image 512x????
PreviewSizeTall PreviewSize = "tall"
)
// IsKnownSize checks if the value is a standard size
func (p PreviewSize) IsKnownSize() bool {
switch p {
case
PreviewSizeThumb,
PreviewSizeLarge,
PreviewSizeTall:
return true
}
return false
}
func getPreviewSize(str string) (PreviewSize, bool) {
switch str {
case string(PreviewSizeThumb):
return PreviewSizeThumb, true
case string(PreviewSizeLarge):
return PreviewSizeLarge, true
case string(PreviewSizeTall):
return PreviewSizeTall, true
}
return PreviewSizeThumb, false
}
func getTheme(str string) (string, bool) {
switch str {
case "light":
return str, true
case "dark":
return str, true
}
return "dark", false
}
type previewRequest struct {
Kind string `json:"kind"`
OrgID int64 `json:"orgId"`
UID string `json:"uid"`
Size PreviewSize `json:"size"`
Theme string `json:"theme"`
}
type previewResponse struct {
Code int `json:"code"` // 200 | 202
Path string `json:"path"` // local file path to serve
URL string `json:"url"` // redirect to this URL
}
// export enum CrawlerMode {
// Thumbs = 'thumbs',
// Analytics = 'analytics', // Enterprise only
// Migrate = 'migrate',
// }
// export enum CrawlerAction {
// Run = 'run',
// Stop = 'stop',
// Queue = 'queue', // TODO (later!) move some to the front
// }
type crawlCmd struct {
Mode string `json:"mode"` // thumbs | analytics | migrate
Action string `json:"action"` // run | stop | queue
Theme string `json:"theme"` // light | dark
User string `json:"user"` // :(
Password string `json:"password"` // :(
Concurrency int `json:"concurrency"` // number of pages to run in parallel
Path string `json:"path"` // eventually for queue
}
type crawConfig struct {
crawlCmd
// Sent to the crawler with each command
URL string `json:"url"`
ScreenshotsFolder string `json:"screenshotsFolder"`
}
type dashRenderer interface {
// Assumes you have already authenticated as admin
GetPreview(req *previewRequest) *previewResponse
// Assumes you have already authenticated as admin
CrawlerCmd(cfg *crawlCmd) (json.RawMessage, error)
}

View File

@ -0,0 +1,268 @@
package thumbs
import (
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/rendering"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web"
"github.com/segmentio/encoding/json"
)
var (
tlog log.Logger = log.New("thumbnails")
)
type Service interface {
Enabled() bool
GetImage(c *models.ReqContext)
SetImage(c *models.ReqContext)
// Must be admin
StartCrawler(c *models.ReqContext) response.Response
StopCrawler(c *models.ReqContext) response.Response
}
func ProvideService(cfg *setting.Cfg, renderService rendering.Service) Service {
if !cfg.IsDashboardPreviesEnabled() {
return &dummyService{}
}
root := filepath.Join(cfg.DataPath, "crawler", "preview")
url := strings.TrimSuffix(cfg.RendererUrl, "/render") + "/scan"
renderer := newRenderHttp(url, crawConfig{
URL: strings.TrimSuffix(cfg.RendererCallbackUrl, "/"),
ScreenshotsFolder: root,
})
tempdir := filepath.Join(cfg.DataPath, "temp")
_ = os.MkdirAll(tempdir, 0700)
return &thumbService{
renderer: renderer,
root: root,
tempdir: tempdir,
}
}
type thumbService struct {
renderer dashRenderer
root string
tempdir string
}
func (hs *thumbService) Enabled() bool {
return true
}
func (hs *thumbService) parseImageReq(c *models.ReqContext, checkSave bool) *previewRequest {
params := web.Params(c.Req)
size, ok := getPreviewSize(params[":size"])
if !ok {
c.JSON(400, map[string]string{"error": "invalid size"})
return nil
}
theme, ok := getTheme(params[":theme"])
if !ok {
c.JSON(400, map[string]string{"error": "invalid theme"})
return nil
}
req := &previewRequest{
Kind: "dash",
OrgID: c.OrgId,
UID: params[":uid"],
Theme: theme,
Size: size,
}
if len(req.UID) < 1 {
c.JSON(400, map[string]string{"error": "missing UID"})
return nil
}
// Check permissions and status
status := hs.getStatus(c, req.UID, checkSave)
if status != 200 {
c.JSON(status, map[string]string{"error": fmt.Sprintf("code: %d", status)})
return nil
}
return req
}
func (hs *thumbService) GetImage(c *models.ReqContext) {
req := hs.parseImageReq(c, false)
if req == nil {
return // already returned value
}
rsp := hs.renderer.GetPreview(req)
if rsp.Code == 200 {
if rsp.Path != "" {
if strings.HasSuffix(rsp.Path, ".webp") {
c.Resp.Header().Set("Content-Type", "image/webp")
} else if strings.HasSuffix(rsp.Path, ".png") {
c.Resp.Header().Set("Content-Type", "image/png")
}
c.Resp.Header().Set("Content-Type", "image/png")
http.ServeFile(c.Resp, c.Req, rsp.Path)
return
}
if rsp.URL != "" {
// todo redirect
fmt.Printf("TODO redirect: %s\n", rsp.URL)
}
}
if rsp.Code == 202 {
c.JSON(202, map[string]string{"path": rsp.Path, "todo": "queue processing"})
return
}
c.JSON(500, map[string]string{"path": rsp.Path, "error": "unknown!"})
}
func (hs *thumbService) SetImage(c *models.ReqContext) {
req := hs.parseImageReq(c, false)
if req == nil {
return // already returned value
}
r := c.Req
// Parse our multipart form, 10 << 20 specifies a maximum
// upload of 10 MB files.
err := r.ParseMultipartForm(10 << 20)
if err != nil {
c.JSON(400, map[string]string{"error": "invalid upload size"})
return
}
// FormFile returns the first file for the given key `myFile`
// it also returns the FileHeader so we can get the Filename,
// the Header and the size of the file
file, handler, err := r.FormFile("file")
if err != nil {
c.JSON(400, map[string]string{"error": "missing multi-part form field named 'file'"})
fmt.Println("error", err)
return
}
defer func() {
_ = file.Close()
}()
tlog.Info("Uploaded File: %+v\n", handler.Filename)
tlog.Info("File Size: %+v\n", handler.Size)
tlog.Info("MIME Header: %+v\n", handler.Header)
// Create a temporary file within our temp-images directory that follows
// a particular naming pattern
tempFile, err := ioutil.TempFile(hs.tempdir, "upload-*")
if err != nil {
c.JSON(400, map[string]string{"error": "error creating temp file"})
fmt.Println("error", err)
tlog.Info("ERROR", "err", handler.Header)
return
}
defer func() {
_ = tempFile.Close()
}()
// read all of the contents of our uploaded file into a
// byte array
fileBytes, err := ioutil.ReadAll(file)
if err != nil {
fmt.Println(err)
}
// write this byte array to our temporary file
_, err = tempFile.Write(fileBytes)
if err != nil {
c.JSON(400, map[string]string{"error": "error writing file"})
fmt.Println("error", err)
return
}
p := getFilePath(hs.root, req)
err = os.Rename(tempFile.Name(), p)
if err != nil {
c.JSON(400, map[string]string{"error": "unable to rename file"})
return
}
c.JSON(200, map[string]int{"OK": len(fileBytes)})
}
func (hs *thumbService) StartCrawler(c *models.ReqContext) response.Response {
body, err := io.ReadAll(c.Req.Body)
if err != nil {
return response.Error(500, "error reading bytes", err)
}
cmd := &crawlCmd{}
err = json.Unmarshal(body, cmd)
if err != nil {
return response.Error(500, "error parsing bytes", err)
}
cmd.Action = "start"
msg, err := hs.renderer.CrawlerCmd(cmd)
if err != nil {
return response.Error(500, "error starting", err)
}
header := make(http.Header)
header.Set("Content-Type", "application/json")
return response.CreateNormalResponse(header, msg, 200)
}
func (hs *thumbService) StopCrawler(c *models.ReqContext) response.Response {
_, err := hs.renderer.CrawlerCmd(&crawlCmd{
Action: "stop",
})
if err != nil {
return response.Error(500, "error stopping crawler", err)
}
result := make(map[string]string)
result["message"] = "Stopping..."
return response.JSON(200, result)
}
// Ideally this service would not require first looking up the full dashboard just to bet the id!
func (hs *thumbService) getStatus(c *models.ReqContext, uid string, checkSave bool) int {
query := models.GetDashboardQuery{Uid: uid, OrgId: c.OrgId}
if err := bus.DispatchCtx(c.Req.Context(), &query); err != nil {
return 404 // not found
}
dash := query.Result
guardian := guardian.New(c.Req.Context(), dash.Id, c.OrgId, c.SignedInUser)
if checkSave {
if canSave, err := guardian.CanSave(); err != nil || !canSave {
return 403 // forbidden
}
return 200
}
if canView, err := guardian.CanView(); err != nil || !canView {
return 403 // forbidden
}
return 200 // found and OK
}

View File

@ -0,0 +1,14 @@
package thumbs
import (
"fmt"
"path/filepath"
)
func getFilePath(root string, req *previewRequest) string {
ext := "webp"
if req.Size != PreviewSizeThumb {
ext = "png"
}
return filepath.Join(root, fmt.Sprintf("%s-%s-%s.%s", req.UID, req.Size, req.Theme, ext))
}

View File

@ -433,6 +433,11 @@ func (cfg Cfg) IsLiveConfigEnabled() bool {
return cfg.FeatureToggles["live-config"]
}
// 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"]

View File

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

View File

@ -0,0 +1,84 @@
import React, { PureComponent } from 'react';
import { CollapsableSection, FileUpload } from '@grafana/ui';
import { getThumbnailURL } from 'app/features/search/components/SearchCard';
interface Props {
uid: string;
}
interface State {}
export class PreviewSettings extends PureComponent<Props, State> {
state: State = {};
doUpload = (evt: EventTarget & HTMLInputElement, isLight?: boolean) => {
const file = evt?.files && evt.files[0];
if (!file) {
console.log('NOPE!', evt);
return;
}
const url = getThumbnailURL(this.props.uid, isLight);
const formData = new FormData();
formData.append('file', file);
fetch(url, {
method: 'POST',
body: formData,
})
.then((response) => response.json())
.then((result) => {
console.log('Success:', result);
location.reload(); //HACK
})
.catch((error) => {
console.error('Error:', error);
});
};
render() {
const { uid } = this.props;
const imgstyle = { maxWidth: 300, maxHeight: 300 };
return (
<CollapsableSection label="Preview settings" isOpen={true}>
<div>DUMMY UI just so we have an upload button!</div>
<table cellSpacing="4">
<thead>
<tr>
<td>[DARK]</td>
<td>[LIGHT]</td>
</tr>
</thead>
<tbody>
<tr>
<td>
<img src={getThumbnailURL(uid, false)} style={imgstyle} />
</td>
<td>
<img src={getThumbnailURL(uid, true)} style={imgstyle} />
</td>
</tr>
<tr>
<td>
<FileUpload
accept="image/png, image/webp"
onFileUpload={({ currentTarget }) => this.doUpload(currentTarget, false)}
>
Upload dark
</FileUpload>
</td>
<td>
<FileUpload
accept="image/png, image/webp"
onFileUpload={({ currentTarget }) => this.doUpload(currentTarget, true)}
>
Upload light
</FileUpload>
</td>
</tr>
</tbody>
</table>
</CollapsableSection>
);
}
}

View File

@ -1,11 +1,12 @@
import React, { FC, FormEvent } from 'react';
import React, { FC, ChangeEvent, FormEvent } from 'react';
import { css } from '@emotion/css';
import { 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">

View File

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

View File

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

View File

@ -0,0 +1,238 @@
import React, { useCallback, useRef, useState } from 'react';
import { css } from '@emotion/css';
import { usePopper } from 'react-popper';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, Portal, TagList, useTheme2 } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
import { backendSrv } from 'app/core/services/backend_srv';
import { DashboardSectionItem, OnToggleChecked } from '../types';
import { SearchCheckbox } from './SearchCheckbox';
import { SearchCardExpanded } from './SearchCardExpanded';
const DELAY_BEFORE_EXPANDING = 500;
export interface Props {
editable?: boolean;
item: DashboardSectionItem;
onTagSelected?: (name: string) => any;
onToggleChecked?: OnToggleChecked;
}
export function getThumbnailURL(uid: string, isLight?: boolean) {
return `/api/dashboards/uid/${uid}/img/thumb/${isLight ? 'light' : 'dark'}`;
}
export function SearchCard({ editable, item, onTagSelected, onToggleChecked }: Props) {
const [hasImage, setHasImage] = useState(true);
const [lastUpdated, setLastUpdated] = useState<string>();
const [showExpandedView, setShowExpandedView] = useState(false);
const timeout = useRef<number | null>(null);
// Popper specific logic
const offsetCallback = useCallback(({ placement, reference, popper }) => {
let result: [number, number] = [0, 0];
if (placement === 'bottom' || placement === 'top') {
result = [0, -(reference.height + popper.height) / 2];
} else if (placement === 'left' || placement === 'right') {
result = [-(reference.width + popper.width) / 2, 0];
}
return result;
}, []);
const [markerElement, setMarkerElement] = React.useState<HTMLDivElement | null>(null);
const [popperElement, setPopperElement] = React.useState<HTMLDivElement | null>(null);
const { styles: popperStyles, attributes } = usePopper(markerElement, popperElement, {
modifiers: [
{
name: 'offset',
options: {
offset: offsetCallback,
},
},
],
});
const theme = useTheme2();
const imageSrc = getThumbnailURL(item.uid!, theme.isLight);
const styles = getStyles(
theme,
markerElement?.getBoundingClientRect().width,
popperElement?.getBoundingClientRect().width
);
const onShowExpandedView = async () => {
setShowExpandedView(true);
if (item.uid && !lastUpdated) {
const dashboard = await backendSrv.getDashboardByUid(item.uid);
const { updated } = dashboard.meta;
setLastUpdated(new Date(updated).toLocaleString());
}
};
const onMouseEnter = () => {
timeout.current = window.setTimeout(onShowExpandedView, DELAY_BEFORE_EXPANDING);
};
const onMouseMove = () => {
if (timeout.current) {
window.clearTimeout(timeout.current);
}
timeout.current = window.setTimeout(onShowExpandedView, DELAY_BEFORE_EXPANDING);
};
const onMouseLeave = () => {
if (timeout.current) {
window.clearTimeout(timeout.current);
}
setShowExpandedView(false);
};
const onCheckboxClick = (ev: React.MouseEvent) => {
ev.stopPropagation();
ev.preventDefault();
onToggleChecked?.(item);
};
const onTagClick = (tag: string, ev: React.MouseEvent) => {
ev.stopPropagation();
ev.preventDefault();
onTagSelected?.(tag);
};
return (
<a
data-testid={selectors.components.Search.dashboardCard(item.title)}
className={styles.card}
key={item.uid}
href={item.url}
ref={(ref) => setMarkerElement((ref as unknown) as HTMLDivElement)}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onMouseMove={onMouseMove}
>
<div className={styles.imageContainer}>
<SearchCheckbox
className={styles.checkbox}
aria-label="Select dashboard"
editable={editable}
checked={item.checked}
onClick={onCheckboxClick}
/>
{hasImage ? (
<img loading="lazy" className={styles.image} src={imageSrc} onError={() => setHasImage(false)} />
) : (
<div className={styles.imagePlaceholder}>
<Icon name="apps" size="xl" />
</div>
)}
</div>
<div className={styles.info}>
<div className={styles.title}>{item.title}</div>
<TagList displayMax={1} tags={item.tags} onClick={onTagClick} />
</div>
{showExpandedView && (
<Portal className={styles.portal}>
<div ref={setPopperElement} style={popperStyles.popper} {...attributes.popper}>
<SearchCardExpanded
className={styles.expandedView}
imageHeight={240}
imageWidth={320}
item={item}
lastUpdated={lastUpdated}
/>
</div>
</Portal>
)}
</a>
);
}
const getStyles = (theme: GrafanaTheme2, markerWidth = 0, popperWidth = 0) => {
const IMAGE_HORIZONTAL_MARGIN = theme.spacing(4);
return {
card: css`
background-color: ${theme.colors.background.secondary};
border: 1px solid ${theme.colors.border.medium};
border-radius: 4px;
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
&:hover {
background-color: ${theme.colors.emphasize(theme.colors.background.secondary, 0.03)};
}
`,
checkbox: css`
left: 0;
margin: ${theme.spacing(1)};
position: absolute;
top: 0;
`,
expandedView: css`
@keyframes expand {
0% {
transform: scale(${markerWidth / popperWidth});
}
100% {
transform: scale(1);
}
}
animation: expand ${theme.transitions.duration.shortest}ms ease-in-out 0s 1 normal;
background-color: ${theme.colors.emphasize(theme.colors.background.secondary, 0.03)};
`,
image: css`
aspect-ratio: 4 / 3;
box-shadow: ${theme.shadows.z1};
margin: ${theme.spacing(1)} ${IMAGE_HORIZONTAL_MARGIN} 0;
width: calc(100% - (2 * ${IMAGE_HORIZONTAL_MARGIN}));
`,
imageContainer: css`
flex: 1;
position: relative;
&:after {
background: linear-gradient(180deg, rgba(196, 196, 196, 0) 0%, rgba(127, 127, 127, 0.25) 100%);
bottom: 0;
content: '';
left: 0;
margin: ${theme.spacing(1)} ${IMAGE_HORIZONTAL_MARGIN} 0;
position: absolute;
right: 0;
top: 0;
}
`,
imagePlaceholder: css`
align-items: center;
aspect-ratio: 4 / 3;
color: ${theme.colors.text.secondary};
display: flex;
justify-content: center;
margin: ${theme.spacing(1)} ${IMAGE_HORIZONTAL_MARGIN} 0;
width: calc(100% - (2 * ${IMAGE_HORIZONTAL_MARGIN}));
`,
info: css`
align-items: center;
background-color: ${theme.colors.background.canvas};
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
display: flex;
height: ${theme.spacing(7)};
gap: ${theme.spacing(1)};
padding: 0 ${theme.spacing(2)};
z-index: 1;
`,
portal: css`
pointer-events: none;
`,
title: css`
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`,
};
};

View File

@ -0,0 +1,153 @@
import React, { useState } from 'react';
import { css } from '@emotion/css';
import classNames from 'classnames';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, Spinner, TagList, useTheme2 } from '@grafana/ui';
import { DashboardSectionItem } from '../types';
import { getThumbnailURL } from './SearchCard';
export interface Props {
className?: string;
imageHeight: number;
imageWidth: number;
item: DashboardSectionItem;
lastUpdated?: string;
}
export function SearchCardExpanded({ className, imageHeight, imageWidth, item, lastUpdated }: Props) {
const theme = useTheme2();
const [hasImage, setHasImage] = useState(true);
const imageSrc = getThumbnailURL(item.uid!, theme.isLight);
const styles = getStyles(theme, imageHeight, imageWidth);
const folderTitle = item.folderTitle || 'General';
return (
<a className={classNames(className, styles.card)} key={item.uid} href={item.url}>
<div className={styles.imageContainer}>
{hasImage ? (
<img
loading="lazy"
className={styles.image}
src={imageSrc}
onLoad={() => setHasImage(true)}
onError={() => setHasImage(false)}
/>
) : (
<div className={styles.imagePlaceholder}>
<Icon name="apps" size="xl" />
</div>
)}
</div>
<div className={styles.info}>
<div className={styles.infoHeader}>
<div className={styles.titleContainer}>
<div>{item.title}</div>
<div className={styles.folder}>
<Icon name={'folder'} />
{folderTitle}
</div>
</div>
<div className={styles.updateContainer}>
<div>Last updated</div>
{lastUpdated ? <div className={styles.update}>{lastUpdated}</div> : <Spinner />}
</div>
</div>
<div>
<TagList className={styles.tagList} tags={item.tags} />
</div>
</div>
</a>
);
}
const getStyles = (theme: GrafanaTheme2, imageHeight: Props['imageHeight'], imageWidth: Props['imageWidth']) => {
const IMAGE_HORIZONTAL_MARGIN = theme.spacing(4);
return {
card: css`
background-color: ${theme.colors.background.secondary};
border: 1px solid ${theme.colors.border.medium};
border-radius: 4px;
box-shadow: ${theme.shadows.z3};
display: flex;
flex-direction: column;
height: 100%;
max-width: calc(${imageWidth}px + (${IMAGE_HORIZONTAL_MARGIN} * 2))};
width: 100%;
`,
folder: css`
align-items: center;
color: ${theme.colors.text.secondary};
display: flex;
font-size: ${theme.typography.size.sm};
gap: ${theme.spacing(0.5)};
`,
image: css`
box-shadow: ${theme.shadows.z2};
height: ${imageHeight}px;
margin: ${theme.spacing(1)} calc(${IMAGE_HORIZONTAL_MARGIN} - 1px) 0;
width: ${imageWidth}px;
`,
imageContainer: css`
flex: 1;
position: relative;
&:after {
background: linear-gradient(180deg, rgba(196, 196, 196, 0) 0%, rgba(127, 127, 127, 0.25) 100%);
bottom: 0;
content: '';
left: 0;
margin: ${theme.spacing(1)} calc(${IMAGE_HORIZONTAL_MARGIN} - 1px) 0;
position: absolute;
right: 0;
top: 0;
}
`,
imagePlaceholder: css`
align-items: center;
color: ${theme.colors.text.secondary};
display: flex;
height: ${imageHeight}px;
justify-content: center;
margin: ${theme.spacing(1)} ${IMAGE_HORIZONTAL_MARGIN} 0;
width: ${imageWidth}px;
`,
info: css`
background-color: ${theme.colors.background.canvas};
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
display: flex;
flex-direction: column;
min-height: ${theme.spacing(7)};
gap: ${theme.spacing(1)};
padding: ${theme.spacing(1)} ${theme.spacing(2)};
z-index: 1;
`,
infoHeader: css`
display: flex;
gap: ${theme.spacing(1)};
justify-content: space-between;
`,
tagList: css`
justify-content: flex-start;
`,
titleContainer: css`
display: flex;
flex-direction: column;
gap: ${theme.spacing(0.5)};
`,
updateContainer: css`
align-items: flex-end;
display: flex;
flex-direction: column;
flex-shrink: 0;
font-size: ${theme.typography.bodySmall.fontSize};
gap: ${theme.spacing(0.5)};
`,
update: css`
color: ${theme.colors.text.secondary};
text-align: right;
`,
};
};

View File

@ -32,10 +32,28 @@ describe('SearchResults', () => {
it('should render section items for expanded section', () => {
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', () => {

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import React, { FC, FormEvent } from 'react';
import React, { FC, ChangeEvent, FormEvent } from 'react';
import { css } from '@emotion/css';
import { 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
/>

View File

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

View File

@ -11,7 +11,8 @@ import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import impressionSrv from 'app/core/services/impression_srv';
import { 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>
)
)}

View File

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

View File

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

View File

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

View File

@ -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)};
`,
});