LibraryPanels: Adds folder name to Library Panel card (#33697)

* LibraryPanels: Adds folder filter

* Refactor: Adds folder filter to library search

* Refactor: splits huge function into smaller functions

* LibraryPanels: Adds Panels Page to Manage Folder tabs (#33618)

* Chore: adds tests to LibraryPanelsSearch

* Refactor: Adds reducer and tests

* Chore: changes GrafanaThemeV2

* Refactor: adds folderName to get all result

* Refactor: adds folderName to get result

* Refactor: adds folder name to LibraryPanelDTOMeta

* Refactor: adds folder name to lbirary panels result

* Chore: reverts public/app/routes/routes.tsx to master

* Minor style tweak

* Refactor: adds folder uid to meta

* Chore: updates after PR comments

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
Hugo Häggmark 2021-05-05 11:09:12 +02:00 committed by GitHub
parent a5c13feb61
commit 605bae8e2c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 146 additions and 16 deletions

View File

@ -14,7 +14,7 @@ import (
)
var (
sqlStatmentLibrayPanelDTOWithMeta = `
selectLibrayPanelDTOWithMeta = `
SELECT DISTINCT
lp.name, lp.id, lp.org_id, lp.folder_id, lp.uid, lp.type, lp.description, lp.model, lp.created, lp.created_by, lp.updated, lp.updated_by, lp.version
, 0 AS can_edit
@ -23,10 +23,13 @@ SELECT DISTINCT
, u2.login AS updated_by_name
, u2.email AS updated_by_email
, (SELECT COUNT(dashboard_id) FROM library_panel_dashboard WHERE librarypanel_id = lp.id) AS connected_dashboards
`
fromLibrayPanelDTOWithMeta = `
FROM library_panel AS lp
LEFT JOIN user AS u1 ON lp.created_by = u1.id
LEFT JOIN user AS u2 ON lp.updated_by = u2.id
`
sqlStatmentLibrayPanelDTOWithMeta = selectLibrayPanelDTOWithMeta + fromLibrayPanelDTOWithMeta
)
func syncFieldsWithModel(libraryPanel *LibraryPanel) error {
@ -328,10 +331,16 @@ func (lps *LibraryPanelService) getLibraryPanel(c *models.ReqContext, uid string
err := lps.SQLStore.WithDbSession(c.Context.Req.Context(), func(session *sqlstore.DBSession) error {
libraryPanels := make([]LibraryPanelWithMeta, 0)
builder := sqlstore.SQLBuilder{}
builder.Write(sqlStatmentLibrayPanelDTOWithMeta)
builder.Write(selectLibrayPanelDTOWithMeta)
builder.Write(", 'General' as folder_name ")
builder.Write(", '' as folder_uid ")
builder.Write(fromLibrayPanelDTOWithMeta)
builder.Write(` WHERE lp.uid=? AND lp.org_id=? AND lp.folder_id=0`, uid, c.SignedInUser.OrgId)
builder.Write(" UNION ")
builder.Write(sqlStatmentLibrayPanelDTOWithMeta)
builder.Write(selectLibrayPanelDTOWithMeta)
builder.Write(", dashboard.title as folder_name ")
builder.Write(", dashboard.uid as folder_uid ")
builder.Write(fromLibrayPanelDTOWithMeta)
builder.Write(" INNER JOIN dashboard AS dashboard on lp.folder_id = dashboard.id AND lp.folder_id <> 0")
builder.Write(` WHERE lp.uid=? AND lp.org_id=?`, uid, c.SignedInUser.OrgId)
if c.SignedInUser.OrgRole != models.ROLE_ADMIN {
@ -365,6 +374,8 @@ func (lps *LibraryPanelService) getLibraryPanel(c *models.ReqContext, uid string
Version: libraryPanel.Version,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
FolderName: libraryPanel.FolderName,
FolderUID: libraryPanel.FolderUID,
ConnectedDashboards: libraryPanel.ConnectedDashboards,
Created: libraryPanel.Created,
Updated: libraryPanel.Updated,
@ -405,14 +416,20 @@ func (lps *LibraryPanelService) getAllLibraryPanels(c *models.ReqContext, query
err := lps.SQLStore.WithDbSession(c.Context.Req.Context(), func(session *sqlstore.DBSession) error {
builder := sqlstore.SQLBuilder{}
if folderFilter.includeGeneralFolder {
builder.Write(sqlStatmentLibrayPanelDTOWithMeta)
builder.Write(selectLibrayPanelDTOWithMeta)
builder.Write(", 'General' as folder_name ")
builder.Write(", '' as folder_uid ")
builder.Write(fromLibrayPanelDTOWithMeta)
builder.Write(` WHERE lp.org_id=? AND lp.folder_id=0`, c.SignedInUser.OrgId)
writeSearchStringSQL(query, lps.SQLStore, &builder)
writeExcludeSQL(query, &builder)
writePanelFilterSQL(panelFilter, &builder)
builder.Write(" UNION ")
}
builder.Write(sqlStatmentLibrayPanelDTOWithMeta)
builder.Write(selectLibrayPanelDTOWithMeta)
builder.Write(", dashboard.title as folder_name ")
builder.Write(", dashboard.uid as folder_uid ")
builder.Write(fromLibrayPanelDTOWithMeta)
builder.Write(" INNER JOIN dashboard AS dashboard on lp.folder_id = dashboard.id AND lp.folder_id<>0")
builder.Write(` WHERE lp.org_id=?`, c.SignedInUser.OrgId)
writeSearchStringSQL(query, lps.SQLStore, &builder)
@ -448,6 +465,8 @@ func (lps *LibraryPanelService) getAllLibraryPanels(c *models.ReqContext, query
Version: panel.Version,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
FolderName: panel.FolderName,
FolderUID: panel.FolderUID,
ConnectedDashboards: panel.ConnectedDashboards,
Created: panel.Created,
Updated: panel.Updated,
@ -526,8 +545,12 @@ func (lps *LibraryPanelService) getLibraryPanelsForDashboardID(c *models.ReqCont
libraryPanelMap := make(map[string]LibraryPanelDTO)
err := lps.SQLStore.WithDbSession(c.Context.Req.Context(), func(session *sqlstore.DBSession) error {
var libraryPanels []LibraryPanelWithMeta
sql := sqlStatmentLibrayPanelDTOWithMeta + "INNER JOIN library_panel_dashboard AS lpd ON lpd.librarypanel_id = lp.id AND lpd.dashboard_id=?"
sess := session.SQL(sql, dashboardID)
sql := selectLibrayPanelDTOWithMeta + ", coalesce(dashboard.title, 'General') AS folder_name, coalesce(dashboard.uid, '') AS folder_uid " + fromLibrayPanelDTOWithMeta + `
LEFT JOIN dashboard AS dashboard ON dashboard.id = lp.folder_id AND dashboard.id=?
INNER JOIN library_panel_dashboard AS lpd ON lpd.librarypanel_id = lp.id AND lpd.dashboard_id=?
`
sess := session.SQL(sql, dashboardID, dashboardID)
err := sess.Find(&libraryPanels)
if err != nil {
return err
@ -546,6 +569,8 @@ func (lps *LibraryPanelService) getLibraryPanelsForDashboardID(c *models.ReqCont
Version: panel.Version,
Meta: LibraryPanelDTOMeta{
CanEdit: panel.CanEdit,
FolderName: panel.FolderName,
FolderUID: panel.FolderUID,
ConnectedDashboards: panel.ConnectedDashboards,
Created: panel.Created,
Updated: panel.Updated,

View File

@ -109,6 +109,8 @@ func (lps *LibraryPanelService) LoadLibraryPanelsForDashboard(c *models.ReqConte
"version": libraryPanelInDB.Version,
"meta": map[string]interface{}{
"canEdit": libraryPanelInDB.Meta.CanEdit,
"folderName": libraryPanelInDB.Meta.FolderName,
"folderUid": libraryPanelInDB.Meta.FolderUID,
"connectedDashboards": libraryPanelInDB.Meta.ConnectedDashboards,
"created": libraryPanelInDB.Meta.Created,
"updated": libraryPanelInDB.Meta.Updated,

View File

@ -71,6 +71,8 @@ func TestGetAllLibraryPanels(t *testing.T) {
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
FolderName: "ScenarioFolder",
FolderUID: sc.folder.Uid,
ConnectedDashboards: 0,
Created: result.Result.LibraryPanels[0].Meta.Created,
Updated: result.Result.LibraryPanels[0].Meta.Updated,
@ -104,6 +106,8 @@ func TestGetAllLibraryPanels(t *testing.T) {
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
FolderName: "ScenarioFolder",
FolderUID: sc.folder.Uid,
ConnectedDashboards: 0,
Created: result.Result.LibraryPanels[1].Meta.Created,
Updated: result.Result.LibraryPanels[1].Meta.Updated,
@ -166,6 +170,8 @@ func TestGetAllLibraryPanels(t *testing.T) {
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
FolderName: "ScenarioFolder",
FolderUID: sc.folder.Uid,
ConnectedDashboards: 0,
Created: result.Result.LibraryPanels[0].Meta.Created,
Updated: result.Result.LibraryPanels[0].Meta.Updated,
@ -199,6 +205,8 @@ func TestGetAllLibraryPanels(t *testing.T) {
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
FolderName: "ScenarioFolder",
FolderUID: sc.folder.Uid,
ConnectedDashboards: 0,
Created: result.Result.LibraryPanels[1].Meta.Created,
Updated: result.Result.LibraryPanels[1].Meta.Updated,
@ -281,6 +289,8 @@ func TestGetAllLibraryPanels(t *testing.T) {
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
FolderName: "ScenarioFolder",
FolderUID: sc.folder.Uid,
ConnectedDashboards: 0,
Created: result.Result.LibraryPanels[0].Meta.Created,
Updated: result.Result.LibraryPanels[0].Meta.Updated,
@ -314,6 +324,8 @@ func TestGetAllLibraryPanels(t *testing.T) {
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
FolderName: "ScenarioFolder",
FolderUID: sc.folder.Uid,
ConnectedDashboards: 0,
Created: result.Result.LibraryPanels[1].Meta.Created,
Updated: result.Result.LibraryPanels[1].Meta.Updated,
@ -414,6 +426,8 @@ func TestGetAllLibraryPanels(t *testing.T) {
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
FolderName: "NewFolder",
FolderUID: newFolder.Uid,
ConnectedDashboards: 0,
Created: result.Result.LibraryPanels[0].Meta.Created,
Updated: result.Result.LibraryPanels[0].Meta.Updated,
@ -507,6 +521,8 @@ func TestGetAllLibraryPanels(t *testing.T) {
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
FolderName: "ScenarioFolder",
FolderUID: sc.folder.Uid,
ConnectedDashboards: 0,
Created: result.Result.LibraryPanels[0].Meta.Created,
Updated: result.Result.LibraryPanels[0].Meta.Updated,
@ -540,6 +556,8 @@ func TestGetAllLibraryPanels(t *testing.T) {
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
FolderName: "ScenarioFolder",
FolderUID: sc.folder.Uid,
ConnectedDashboards: 0,
Created: result.Result.LibraryPanels[1].Meta.Created,
Updated: result.Result.LibraryPanels[1].Meta.Updated,
@ -602,6 +620,8 @@ func TestGetAllLibraryPanels(t *testing.T) {
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
FolderName: "ScenarioFolder",
FolderUID: sc.folder.Uid,
ConnectedDashboards: 0,
Created: result.Result.LibraryPanels[0].Meta.Created,
Updated: result.Result.LibraryPanels[0].Meta.Updated,
@ -664,6 +684,8 @@ func TestGetAllLibraryPanels(t *testing.T) {
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
FolderName: "ScenarioFolder",
FolderUID: sc.folder.Uid,
ConnectedDashboards: 0,
Created: result.Result.LibraryPanels[0].Meta.Created,
Updated: result.Result.LibraryPanels[0].Meta.Updated,
@ -727,6 +749,8 @@ func TestGetAllLibraryPanels(t *testing.T) {
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
FolderName: "ScenarioFolder",
FolderUID: sc.folder.Uid,
ConnectedDashboards: 0,
Created: result.Result.LibraryPanels[0].Meta.Created,
Updated: result.Result.LibraryPanels[0].Meta.Updated,
@ -799,6 +823,8 @@ func TestGetAllLibraryPanels(t *testing.T) {
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
FolderName: "ScenarioFolder",
FolderUID: sc.folder.Uid,
ConnectedDashboards: 0,
Created: result.Result.LibraryPanels[0].Meta.Created,
Updated: result.Result.LibraryPanels[0].Meta.Updated,
@ -869,6 +895,8 @@ func TestGetAllLibraryPanels(t *testing.T) {
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
FolderName: "ScenarioFolder",
FolderUID: sc.folder.Uid,
ConnectedDashboards: 0,
Created: result.Result.LibraryPanels[0].Meta.Created,
Updated: result.Result.LibraryPanels[0].Meta.Updated,
@ -902,6 +930,8 @@ func TestGetAllLibraryPanels(t *testing.T) {
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
FolderName: "ScenarioFolder",
FolderUID: sc.folder.Uid,
ConnectedDashboards: 0,
Created: result.Result.LibraryPanels[1].Meta.Created,
Updated: result.Result.LibraryPanels[1].Meta.Updated,
@ -966,6 +996,8 @@ func TestGetAllLibraryPanels(t *testing.T) {
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
FolderName: "ScenarioFolder",
FolderUID: sc.folder.Uid,
ConnectedDashboards: 0,
Created: result.Result.LibraryPanels[0].Meta.Created,
Updated: result.Result.LibraryPanels[0].Meta.Updated,

View File

@ -41,6 +41,8 @@ func TestGetLibraryPanel(t *testing.T) {
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
FolderName: "ScenarioFolder",
FolderUID: sc.folder.Uid,
ConnectedDashboards: 0,
Created: result.Result.Meta.Created,
Updated: result.Result.Meta.Updated,

View File

@ -337,6 +337,8 @@ func TestLibraryPanelPermissions(t *testing.T) {
result.Result.Meta.CreatedBy.AvatarUrl = UserInDbAvatar
result.Result.Meta.UpdatedBy.Name = UserInDbName
result.Result.Meta.UpdatedBy.AvatarUrl = UserInDbAvatar
result.Result.Meta.FolderName = folder.Title
result.Result.Meta.FolderUID = folder.Uid
results = append(results, result.Result)
}
sc.reqContext.SignedInUser.OrgRole = testCase.role
@ -382,6 +384,7 @@ func TestLibraryPanelPermissions(t *testing.T) {
result.Result.Meta.CreatedBy.AvatarUrl = UserInDbAvatar
result.Result.Meta.UpdatedBy.Name = UserInDbName
result.Result.Meta.UpdatedBy.AvatarUrl = UserInDbAvatar
result.Result.Meta.FolderName = "General"
sc.reqContext.SignedInUser.OrgRole = testCase.role
resp = sc.service.getAllHandler(sc.reqContext)
@ -442,6 +445,8 @@ func TestLibraryPanelPermissions(t *testing.T) {
result.Result.Meta.CreatedBy.AvatarUrl = UserInDbAvatar
result.Result.Meta.UpdatedBy.Name = UserInDbName
result.Result.Meta.UpdatedBy.AvatarUrl = UserInDbAvatar
result.Result.Meta.FolderName = folder.Title
result.Result.Meta.FolderUID = folder.Uid
results = append(results, result.Result)
}
sc.reqContext.SignedInUser.OrgRole = testCase.role
@ -462,6 +467,8 @@ func TestLibraryPanelPermissions(t *testing.T) {
result.Result.Meta.CreatedBy.AvatarUrl = UserInDbAvatar
result.Result.Meta.UpdatedBy.Name = UserInDbName
result.Result.Meta.UpdatedBy.AvatarUrl = UserInDbAvatar
result.Result.Meta.FolderName = "General"
result.Result.Meta.FolderUID = ""
sc.reqContext.SignedInUser.OrgRole = testCase.role
sc.reqContext.ReplaceAllParams(map[string]string{":uid": result.Result.UID})

View File

@ -94,6 +94,8 @@ func TestLoadLibraryPanelsForDashboard(t *testing.T) {
"version": sc.initialResult.Result.Version,
"meta": map[string]interface{}{
"canEdit": false,
"folderName": "ScenarioFolder",
"folderUid": sc.folder.Uid,
"connectedDashboards": int64(1),
"created": sc.initialResult.Result.Meta.Created,
"updated": sc.initialResult.Result.Meta.Updated,

View File

@ -41,6 +41,8 @@ type LibraryPanelWithMeta struct {
Updated time.Time
CanEdit bool
FolderName string
FolderUID string `xorm:"folder_uid"`
ConnectedDashboards int64
CreatedBy int64
UpdatedBy int64
@ -74,8 +76,10 @@ type LibraryPanelSearchResult struct {
// LibraryPanelDTOMeta is the meta information for LibraryPanelDTO.
type LibraryPanelDTOMeta struct {
CanEdit bool `json:"canEdit"`
ConnectedDashboards int64 `json:"connectedDashboards"`
CanEdit bool `json:"canEdit"`
FolderName string `json:"folderName"`
FolderUID string `json:"folderUid"`
ConnectedDashboards int64 `json:"connectedDashboards"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`

View File

@ -24,6 +24,7 @@ export const PanelTypeCard: React.FC<Props> = ({
disabled,
showBadge,
description,
children,
}) => {
const styles = useStyles2(getStyles);
const cssClass = cx({
@ -44,6 +45,7 @@ export const PanelTypeCard: React.FC<Props> = ({
<div className={styles.itemContent}>
<div className={styles.name}>{title}</div>
{description ? <span className={styles.description}>{description}</span> : null}
{children}
</div>
{showBadge && (
<div className={cx(styles.badge, disabled && styles.disabled)}>
@ -82,7 +84,6 @@ const getStyles = (theme: GrafanaTheme2) => {
width: 100%;
position: relative;
overflow: hidden;
height: 55px;
transition: ${theme.transitions.create(['background'], {
duration: theme.transitions.duration.short,
})};
@ -94,6 +95,7 @@ const getStyles = (theme: GrafanaTheme2) => {
itemContent: css`
position: relative;
width: 100%;
padding: ${theme.spacing(0, 1)};
`,
current: css`
label: currentVisualizationItem;
@ -111,7 +113,6 @@ const getStyles = (theme: GrafanaTheme2) => {
white-space: nowrap;
font-size: ${theme.typography.size.sm};
font-weight: ${theme.typography.fontWeightMedium};
padding: 0 10px;
width: 100%;
`,
description: css`
@ -121,7 +122,6 @@ const getStyles = (theme: GrafanaTheme2) => {
color: ${theme.colors.text.secondary};
font-size: ${theme.typography.bodySmall.fontSize};
font-weight: ${theme.typography.fontWeightLight};
padding: 0 ${theme.spacing(1.25)};
width: 100%;
`,
img: css`

View File

@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { useAsync } from 'react-use';
import { GrafanaRouteComponentProps } from '../../core/navigation/types';
import { StoreState } from '../../types';
@ -10,7 +11,6 @@ import Page from '../../core/components/Page/Page';
import { LibraryPanelsSearch } from '../library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch';
import { OpenLibraryPanelModal } from '../library-panels/components/OpenLibraryPanelModal/OpenLibraryPanelModal';
import { getFolderByUid } from './state/actions';
import { useAsync } from 'react-use';
export interface OwnProps extends GrafanaRouteComponentProps<{ uid: string }> {}

View File

@ -1,5 +1,6 @@
import { FolderDTO } from 'app/types';
import { NavModel, NavModelItem } from '@grafana/data';
import { FolderDTO } from 'app/types';
import { getConfig } from '../../../core/config';
export function buildNavModel(folder: FolderDTO): NavModelItem {

View File

@ -1,6 +1,8 @@
import React, { useState } from 'react';
import { PanelPluginMeta } from '@grafana/data';
import { css } from '@emotion/css';
import { GrafanaTheme2, PanelPluginMeta } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Icon, Link, useStyles2 } from '@grafana/ui';
import { LibraryPanelDTO } from '../../types';
import { PanelTypeCard } from 'app/features/dashboard/components/VizTypePicker/PanelTypeCard';
@ -37,7 +39,9 @@ export const LibraryPanelCard: React.FC<LibraryPanelCardProps & { children?: JSX
plugin={panelPlugin}
onClick={() => onClick(libraryPanel)}
onDelete={showSecondaryActions ? () => setShowDeletionModal(true) : undefined}
/>
>
<FolderLink libraryPanel={libraryPanel} />
</PanelTypeCard>
{showDeletionModal && (
<DeleteLibraryPanelModal
libraryPanel={libraryPanel}
@ -48,3 +52,46 @@ export const LibraryPanelCard: React.FC<LibraryPanelCardProps & { children?: JSX
</>
);
};
interface FolderLinkProps {
libraryPanel: LibraryPanelDTO;
}
function FolderLink({ libraryPanel }: FolderLinkProps): JSX.Element {
const styles = useStyles2(getStyles);
if (!libraryPanel.meta.folderUid) {
return (
<span className={styles.metaContainer}>
<Icon name={'folder'} size="sm" />
{libraryPanel.meta.folderName}
</span>
);
}
return (
<Link href={`/dashboards/f/${libraryPanel.meta.folderUid}`}>
<span className={styles.metaContainer}>
<Icon name={'folder-upload'} size="sm" />
{libraryPanel.meta.folderName}
</span>
</Link>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
metaContainer: css`
display: flex;
align-items: center;
color: ${theme.colors.text.disabled};
font-size: ${theme.typography.bodySmall.fontSize};
padding-top: ${theme.spacing(0.5)};
svg {
margin-right: ${theme.spacing(0.5)};
margin-bottom: 3px;
}
`,
};
}

View File

@ -216,6 +216,8 @@ describe('LibraryPanelsSearch', () => {
version: 1,
meta: {
canEdit: true,
folderName: 'General',
folderUid: '',
connectedDashboards: 0,
created: '2021-01-01 12:00:00',
createdBy: { id: 1, name: 'Admin', avatarUrl: '' },
@ -258,6 +260,8 @@ describe('LibraryPanelsSearch', () => {
version: 1,
meta: {
canEdit: true,
folderName: 'General',
folderUid: '',
connectedDashboards: 0,
created: '2021-01-01 12:00:00',
createdBy: { id: 1, name: 'Admin', avatarUrl: '' },

View File

@ -110,6 +110,8 @@ function mockLibraryPanel({
model = { type: 'text', title: 'Test Panel' },
meta = {
canEdit: true,
folderName: 'General',
folderUid: '',
connectedDashboards: 0,
created: '2021-01-01T00:00:00',
createdBy: { id: 1, name: 'User X', avatarUrl: '/avatar/abc' },

View File

@ -24,6 +24,8 @@ export interface LibraryPanelDTO {
export interface LibraryPanelDTOMeta {
canEdit: boolean;
folderName: string;
folderUid: string;
connectedDashboards: number;
created: string;
updated: string;