mirror of https://github.com/grafana/grafana
Explore: List query templates (#86897)
* Create basic feature toggle * Rename context to reflect it contains query history and query library * Update icons and variants * Rename hooks * Update tests * Fix mock * Add tracking * Turn button into a toggle * Make dropdown active as well This is required to have better UI and an indication of selected state in split view * Update Query Library icon This is to make it consistent with the toolbar button * Hide query history button when query library is available This is to avoid confusing UX with 2 button triggering the drawer but with slightly different behavior * Make the drawer bigger for query library To avoid confusion for current users and test it internally a bit more it's behind a feature toggle. Bigger drawer may obstruct the view and add more friction in the UX. * Fix tests The test was failing because queryLibraryAvailable was set to true for tests. This change makes it more explicit what use case is being tested * Remove active state underline from the dropdown * Add basic types and api methods This is just moved from the app. To be cleaned up and refactored later. * Move API utils from Query Library app to Grafana packages * Move API utils from Query Library app to Grafana packages * Move API utils from Query Library app to Grafana packages * Add basic table for query templates * Add sorting * Style cells * Style table cells * Allow closing Query Library drawer from the toolbar * Remove Private Query toggle It will be moved to the kebab * Add empty state * Remove variables detection for now Just to simplify the PR, it's not needed for Explore yet. * Simplify getting useDatasource.tsx * Rename cell * Move QueryTemplatesTable to a separate folder * Use RTK Query to get list of query templates * Clean up query templates table * Simplify useDatasource hook * Add a test * Retrigger the build * Remove unused code * Small clean up * Update import * Add reduxjs/toolkit as a peer dependecy * Revert "Add reduxjs/toolkit as a peer dependecy" This reverts commitpull/87790/headaa9da6e442. * Update import * Add reduxjs/toolkit as a peer dependecy * Revert "Add reduxjs/toolkit as a peer dependecy" This reverts commit2e68a62ab6. * Add @reduxjs/toolkit as peer dependency * Add @reduxjs/toolkit as peer dependecy * Move reactjs/toolkit to dev dependecies * Minor clean up and use react-redux as a peer dependency * Move query library code to core features * Update code owners * Update export * Update exports * Use Redux store instead of APIProvider * Await for query templates to show during the test * Add more explicit docs that the feature is experimental --------- Co-authored-by: Kristina Durivage <kristina.durivage@grafana.com>
parent
6b1a662f6b
commit
fd218edca4
@ -0,0 +1,7 @@ |
||||
import React from 'react'; |
||||
|
||||
import { QueryTemplatesList } from './QueryTemplatesList'; |
||||
|
||||
export function QueryLibrary() { |
||||
return <QueryTemplatesList />; |
||||
} |
||||
@ -0,0 +1,53 @@ |
||||
import React from 'react'; |
||||
|
||||
import { EmptyState, Spinner } from '@grafana/ui'; |
||||
import { useAllQueryTemplatesQuery } from 'app/features/query-library'; |
||||
import { QueryTemplate } from 'app/features/query-library/types'; |
||||
|
||||
import { getDatasourceSrv } from '../../plugins/datasource_srv'; |
||||
|
||||
import QueryTemplatesTable from './QueryTemplatesTable'; |
||||
import { QueryTemplateRow } from './QueryTemplatesTable/types'; |
||||
|
||||
export function QueryTemplatesList() { |
||||
const { data, isLoading, error } = useAllQueryTemplatesQuery(); |
||||
|
||||
if (error) { |
||||
return ( |
||||
<EmptyState variant="not-found" message={`Something went wrong`}> |
||||
{error.message} |
||||
</EmptyState> |
||||
); |
||||
} |
||||
|
||||
if (isLoading) { |
||||
return <Spinner />; |
||||
} |
||||
|
||||
if (!data || data.length === 0) { |
||||
return ( |
||||
<EmptyState message={`Query Library`} variant="not-found"> |
||||
<p> |
||||
{ |
||||
"You haven't saved any queries to your library yet. Start adding them from Explore or your Query History tab." |
||||
} |
||||
</p> |
||||
</EmptyState> |
||||
); |
||||
} |
||||
|
||||
const queryTemplateRows: QueryTemplateRow[] = data.map((queryTemplate: QueryTemplate, index: number) => { |
||||
const datasourceRef = queryTemplate.targets[0]?.datasource; |
||||
const datasourceType = getDatasourceSrv().getInstanceSettings(datasourceRef)?.meta.name || ''; |
||||
return { |
||||
index: index.toString(), |
||||
datasourceRef, |
||||
datasourceType, |
||||
createdAtTimestamp: queryTemplate?.createdAtTimestamp || 0, |
||||
query: queryTemplate.targets[0], |
||||
description: queryTemplate.title, |
||||
}; |
||||
}); |
||||
|
||||
return <QueryTemplatesTable queryTemplateRows={queryTemplateRows} />; |
||||
} |
||||
@ -0,0 +1,13 @@ |
||||
import React from 'react'; |
||||
|
||||
import { Button } from '@grafana/ui'; |
||||
|
||||
export function ActionsCell() { |
||||
return ( |
||||
<> |
||||
<Button disabled={true} variant="primary"> |
||||
Run |
||||
</Button> |
||||
</> |
||||
); |
||||
} |
||||
@ -0,0 +1,18 @@ |
||||
import React from 'react'; |
||||
|
||||
import { Avatar } from '@grafana/ui'; |
||||
|
||||
import { useQueryLibraryListStyles } from './styles'; |
||||
|
||||
export function AddedByCell() { |
||||
const styles = useQueryLibraryListStyles(); |
||||
|
||||
return ( |
||||
<div> |
||||
<span className={styles.logo}> |
||||
<Avatar src="https://secure.gravatar.com/avatar" alt="unknown" /> |
||||
</span> |
||||
<span className={styles.otherText}>Unknown</span> |
||||
</div> |
||||
); |
||||
} |
||||
@ -0,0 +1,14 @@ |
||||
import React from 'react'; |
||||
import { CellProps } from 'react-table'; |
||||
|
||||
import { useDatasource } from '../utils/useDatasource'; |
||||
|
||||
import { useQueryLibraryListStyles } from './styles'; |
||||
import { QueryTemplateRow } from './types'; |
||||
|
||||
export function DatasourceTypeCell(props: CellProps<QueryTemplateRow>) { |
||||
const datasourceApi = useDatasource(props.row.original.datasourceRef); |
||||
const styles = useQueryLibraryListStyles(); |
||||
|
||||
return <p className={styles.otherText}>{datasourceApi?.meta.name}</p>; |
||||
} |
||||
@ -0,0 +1,14 @@ |
||||
import React from 'react'; |
||||
import { CellProps } from 'react-table'; |
||||
|
||||
import { dateTime } from '@grafana/data'; |
||||
|
||||
import { useQueryLibraryListStyles } from './styles'; |
||||
import { QueryTemplateRow } from './types'; |
||||
|
||||
export function DateAddedCell(props: CellProps<QueryTemplateRow>) { |
||||
const styles = useQueryLibraryListStyles(); |
||||
const formattedTime = dateTime(props.row.original.createdAtTimestamp).format('YYYY-MM-DD HH:mm:ss'); |
||||
|
||||
return <p className={styles.otherText}>{formattedTime}</p>; |
||||
} |
||||
@ -0,0 +1,41 @@ |
||||
import { cx } from '@emotion/css'; |
||||
import React from 'react'; |
||||
import { CellProps } from 'react-table'; |
||||
|
||||
import { Spinner } from '@grafana/ui'; |
||||
|
||||
import { useDatasource } from '../utils/useDatasource'; |
||||
|
||||
import { useQueryLibraryListStyles } from './styles'; |
||||
import { QueryTemplateRow } from './types'; |
||||
|
||||
export function QueryDescriptionCell(props: CellProps<QueryTemplateRow>) { |
||||
const datasourceApi = useDatasource(props.row.original.datasourceRef); |
||||
const styles = useQueryLibraryListStyles(); |
||||
|
||||
if (!datasourceApi) { |
||||
return <Spinner />; |
||||
} |
||||
|
||||
if (!props.row.original.query) { |
||||
return <div>No queries</div>; |
||||
} |
||||
const query = props.row.original.query; |
||||
const description = props.row.original.description; |
||||
const dsName = datasourceApi?.name || ''; |
||||
|
||||
return ( |
||||
<div aria-label={`Query template for ${dsName}: ${description}`}> |
||||
<p className={styles.header}> |
||||
<img |
||||
className={styles.logo} |
||||
src={datasourceApi?.meta.info.logos.small || 'public/img/icn-datasource.svg'} |
||||
alt={datasourceApi?.meta.info.description} |
||||
/> |
||||
{dsName} |
||||
</p> |
||||
<p className={cx(styles.mainText, styles.singleLine)}>{datasourceApi?.getQueryDisplayText?.(query)}</p> |
||||
<p className={cx(styles.otherText, styles.singleLine)}>{description}</p> |
||||
</div> |
||||
); |
||||
} |
||||
@ -0,0 +1,49 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React from 'react'; |
||||
import { SortByFn } from 'react-table'; |
||||
|
||||
import { Column, InteractiveTable } from '@grafana/ui'; |
||||
|
||||
import { ActionsCell } from './ActionsCell'; |
||||
import { AddedByCell } from './AddedByCell'; |
||||
import { DatasourceTypeCell } from './DatasourceTypeCell'; |
||||
import { DateAddedCell } from './DateAddedCell'; |
||||
import { QueryDescriptionCell } from './QueryDescriptionCell'; |
||||
import { QueryTemplateRow } from './types'; |
||||
|
||||
const timestampSort: SortByFn<QueryTemplateRow> = (rowA, rowB, _, desc) => { |
||||
const timeA = rowA.original.createdAtTimestamp || 0; |
||||
const timeB = rowB.original.createdAtTimestamp || 0; |
||||
return desc ? timeA - timeB : timeB - timeA; |
||||
}; |
||||
|
||||
const columns: Array<Column<QueryTemplateRow>> = [ |
||||
{ id: 'description', header: 'Data source and query', cell: QueryDescriptionCell }, |
||||
{ id: 'addedBy', header: 'Added by', cell: AddedByCell }, |
||||
{ id: 'datasourceType', header: 'Datasource type', cell: DatasourceTypeCell, sortType: 'string' }, |
||||
{ id: 'createdAtTimestamp', header: 'Date added', cell: DateAddedCell, sortType: timestampSort }, |
||||
{ id: 'actions', header: '', cell: ActionsCell }, |
||||
]; |
||||
|
||||
const styles = { |
||||
tableWithSpacing: css({ |
||||
'th:first-child': { |
||||
width: '50%', |
||||
}, |
||||
}), |
||||
}; |
||||
|
||||
type Props = { |
||||
queryTemplateRows: QueryTemplateRow[]; |
||||
}; |
||||
|
||||
export default function QueryTemplatesTable({ queryTemplateRows }: Props) { |
||||
return ( |
||||
<InteractiveTable |
||||
className={styles.tableWithSpacing} |
||||
columns={columns} |
||||
data={queryTemplateRows} |
||||
getRowId={(row: { index: string }) => row.index} |
||||
/> |
||||
); |
||||
} |
||||
@ -0,0 +1,37 @@ |
||||
import { css } from '@emotion/css'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data/'; |
||||
import { useStyles2 } from '@grafana/ui/'; |
||||
|
||||
export const useQueryLibraryListStyles = () => { |
||||
return useStyles2(getStyles); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
logo: css({ |
||||
marginRight: theme.spacing(2), |
||||
width: '16px', |
||||
}), |
||||
header: css({ |
||||
margin: 0, |
||||
fontSize: theme.typography.h5.fontSize, |
||||
color: theme.colors.text.secondary, |
||||
}), |
||||
mainText: css({ |
||||
margin: 0, |
||||
fontSize: theme.typography.body.fontSize, |
||||
textOverflow: 'ellipsis', |
||||
}), |
||||
otherText: css({ |
||||
margin: 0, |
||||
fontSize: theme.typography.body.fontSize, |
||||
color: theme.colors.text.secondary, |
||||
textOverflow: 'ellipsis', |
||||
}), |
||||
singleLine: css({ |
||||
display: '-webkit-box', |
||||
'-webkit-box-orient': 'vertical', |
||||
'-webkit-line-clamp': '1', |
||||
overflow: 'hidden', |
||||
}), |
||||
}); |
||||
@ -0,0 +1,10 @@ |
||||
import { DataQuery, DataSourceRef } from '@grafana/schema'; |
||||
|
||||
export type QueryTemplateRow = { |
||||
index: string; |
||||
description?: string; |
||||
query?: DataQuery; |
||||
datasourceRef?: DataSourceRef | null; |
||||
datasourceType?: string; |
||||
createdAtTimestamp?: number; |
||||
}; |
||||
@ -0,0 +1,9 @@ |
||||
import { useAsync } from 'react-use'; |
||||
|
||||
import { getDataSourceSrv } from '@grafana/runtime'; |
||||
import { DataSourceRef } from '@grafana/schema'; |
||||
|
||||
export function useDatasource(dataSourceRef?: DataSourceRef | null) { |
||||
const { value } = useAsync(async () => await getDataSourceSrv().get(dataSourceRef), [dataSourceRef]); |
||||
return value; |
||||
} |
||||
@ -0,0 +1,76 @@ |
||||
import React from 'react'; |
||||
import { Props } from 'react-virtualized-auto-sizer'; |
||||
|
||||
import { EventBusSrv } from '@grafana/data'; |
||||
import { config } from '@grafana/runtime'; |
||||
|
||||
import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput'; |
||||
|
||||
import { assertQueryLibraryTemplateExists } from './helper/assert'; |
||||
import { openQueryLibrary } from './helper/interactions'; |
||||
import { setupExplore, waitForExplore } from './helper/setup'; |
||||
|
||||
const reportInteractionMock = jest.fn(); |
||||
const testEventBus = new EventBusSrv(); |
||||
|
||||
jest.mock('@grafana/runtime', () => ({ |
||||
...jest.requireActual('@grafana/runtime'), |
||||
reportInteraction: (...args: object[]) => { |
||||
reportInteractionMock(...args); |
||||
}, |
||||
getAppEvents: () => testEventBus, |
||||
})); |
||||
|
||||
jest.mock('app/core/core', () => ({ |
||||
contextSrv: { |
||||
hasPermission: () => true, |
||||
isSignedIn: true, |
||||
getValidIntervals: (defaultIntervals: string[]) => defaultIntervals, |
||||
}, |
||||
})); |
||||
|
||||
jest.mock('app/core/services/PreferencesService', () => ({ |
||||
PreferencesService: function () { |
||||
return { |
||||
patch: jest.fn(), |
||||
load: jest.fn().mockResolvedValue({ |
||||
queryHistory: { |
||||
homeTab: 'query', |
||||
}, |
||||
}), |
||||
}; |
||||
}, |
||||
})); |
||||
|
||||
jest.mock('../hooks/useExplorePageTitle', () => ({ |
||||
useExplorePageTitle: jest.fn(), |
||||
})); |
||||
|
||||
jest.mock('react-virtualized-auto-sizer', () => { |
||||
return { |
||||
__esModule: true, |
||||
default(props: Props) { |
||||
return <div>{props.children({ height: 1, scaledHeight: 1, scaledWidth: 1000, width: 1000 })}</div>; |
||||
}, |
||||
}; |
||||
}); |
||||
|
||||
describe('QueryLibrary', () => { |
||||
silenceConsoleOutput(); |
||||
|
||||
beforeAll(() => { |
||||
config.featureToggles.queryLibrary = true; |
||||
}); |
||||
|
||||
afterAll(() => { |
||||
config.featureToggles.queryLibrary = false; |
||||
}); |
||||
|
||||
it('Load query templates', async () => { |
||||
setupExplore(); |
||||
await waitForExplore(); |
||||
await openQueryLibrary(); |
||||
await assertQueryLibraryTemplateExists('loki', 'Loki Query Template'); |
||||
await assertQueryLibraryTemplateExists('elastic', 'Elastic Query Template'); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,17 @@ |
||||
import { createApi } from '@reduxjs/toolkit/query/react'; |
||||
|
||||
import { QueryTemplate } from '../types'; |
||||
|
||||
import { convertDataQueryResponseToQueryTemplates } from './mappers'; |
||||
import { baseQuery } from './query'; |
||||
|
||||
export const queryLibraryApi = createApi({ |
||||
baseQuery, |
||||
endpoints: (builder) => ({ |
||||
allQueryTemplates: builder.query<QueryTemplate[], void>({ |
||||
query: () => undefined, |
||||
transformResponse: convertDataQueryResponseToQueryTemplates, |
||||
}), |
||||
}), |
||||
reducerPath: 'queryLibrary', |
||||
}); |
||||
@ -0,0 +1,17 @@ |
||||
import { QueryTemplate } from '../types'; |
||||
|
||||
import { DataQuerySpecResponse, DataQueryTarget } from './types'; |
||||
|
||||
export const convertDataQueryResponseToQueryTemplates = (result: DataQuerySpecResponse): QueryTemplate[] => { |
||||
if (!result.items) { |
||||
return []; |
||||
} |
||||
return result.items.map((spec) => { |
||||
return { |
||||
uid: spec.metadata.name || '', |
||||
title: spec.spec.title, |
||||
targets: spec.spec.targets.map((target: DataQueryTarget) => target.properties), |
||||
createdAtTimestamp: new Date(spec.metadata.creationTimestamp || '').getTime(), |
||||
}; |
||||
}); |
||||
}; |
||||
@ -0,0 +1,9 @@ |
||||
import { BASE_URL } from './query'; |
||||
import { getTestQueryList } from './testdata/testQueryList'; |
||||
|
||||
export const mockData = { |
||||
all: { |
||||
url: BASE_URL, |
||||
response: getTestQueryList(), |
||||
}, |
||||
}; |
||||
@ -0,0 +1,34 @@ |
||||
import { BaseQueryFn } from '@reduxjs/toolkit/query/react'; |
||||
import { lastValueFrom } from 'rxjs'; |
||||
|
||||
import { getBackendSrv, isFetchError } from '@grafana/runtime/src/services/backendSrv'; |
||||
|
||||
import { DataQuerySpecResponse } from './types'; |
||||
|
||||
/** |
||||
* Query Library is an experimental feature. API (including the URL path) will likely change. |
||||
* |
||||
* @alpha |
||||
*/ |
||||
export const BASE_URL = '/apis/peakq.grafana.app/v0alpha1/namespaces/default/querytemplates/'; |
||||
|
||||
/** |
||||
* TODO: similar code is duplicated in many places. To be unified in #86960 |
||||
*/ |
||||
export const baseQuery: BaseQueryFn<void, DataQuerySpecResponse, Error> = async () => { |
||||
try { |
||||
const responseObservable = getBackendSrv().fetch<DataQuerySpecResponse>({ |
||||
url: BASE_URL, |
||||
showErrorAlert: true, |
||||
}); |
||||
return await lastValueFrom(responseObservable); |
||||
} catch (error) { |
||||
if (isFetchError(error)) { |
||||
return { error: new Error(error.data.message) }; |
||||
} else if (error instanceof Error) { |
||||
return { error }; |
||||
} else { |
||||
return { error: new Error('Unknown error') }; |
||||
} |
||||
} |
||||
}; |
||||
@ -0,0 +1,122 @@ |
||||
export const getTestQueryList = () => ({ |
||||
kind: 'QueryTemplateList', |
||||
apiVersion: 'peakq.grafana.app/v0alpha1', |
||||
metadata: { |
||||
resourceVersion: '1783293408052252672', |
||||
remainingItemCount: 0, |
||||
}, |
||||
items: [ |
||||
{ |
||||
kind: 'QueryTemplate', |
||||
apiVersion: 'peakq.grafana.app/v0alpha1', |
||||
metadata: { |
||||
name: 'AElastic2nkf9', |
||||
generateName: 'AElastic', |
||||
namespace: 'default', |
||||
uid: '65327fce-c545-489d-ada5-16f909453d12', |
||||
resourceVersion: '1783293341664808960', |
||||
creationTimestamp: '2024-04-25T20:32:58Z', |
||||
}, |
||||
spec: { |
||||
title: 'Elastic Query Template', |
||||
targets: [ |
||||
{ |
||||
variables: {}, |
||||
properties: { |
||||
refId: 'A', |
||||
datasource: { |
||||
type: 'elasticsearch', |
||||
uid: 'elastic-uid', |
||||
}, |
||||
alias: '', |
||||
metrics: [ |
||||
{ |
||||
id: '1', |
||||
type: 'count', |
||||
}, |
||||
], |
||||
bucketAggs: [ |
||||
{ |
||||
field: '@timestamp', |
||||
id: '2', |
||||
settings: { |
||||
interval: 'auto', |
||||
}, |
||||
type: 'date_histogram', |
||||
}, |
||||
], |
||||
timeField: '@timestamp', |
||||
query: 'test:test ', |
||||
}, |
||||
}, |
||||
], |
||||
}, |
||||
}, |
||||
{ |
||||
kind: 'QueryTemplate', |
||||
apiVersion: 'peakq.grafana.app/v0alpha1', |
||||
metadata: { |
||||
name: 'ALoki296tj', |
||||
generateName: 'ALoki', |
||||
namespace: 'default', |
||||
uid: '3e71de65-efa7-40e3-8f23-124212cca455', |
||||
resourceVersion: '1783214217151647744', |
||||
creationTimestamp: '2024-04-25T11:05:55Z', |
||||
}, |
||||
spec: { |
||||
title: 'Loki Query Template', |
||||
vars: [ |
||||
{ |
||||
key: '__value', |
||||
defaultValues: [''], |
||||
valueListDefinition: { |
||||
customValues: '', |
||||
}, |
||||
}, |
||||
], |
||||
targets: [ |
||||
{ |
||||
variables: { |
||||
__value: [ |
||||
{ |
||||
path: '$.datasource.jsonData.derivedFields.0.url', |
||||
position: { |
||||
start: 0, |
||||
end: 14, |
||||
}, |
||||
format: 'raw', |
||||
}, |
||||
{ |
||||
path: '$.datasource.jsonData.derivedFields.1.url', |
||||
position: { |
||||
start: 0, |
||||
end: 14, |
||||
}, |
||||
format: 'raw', |
||||
}, |
||||
{ |
||||
path: '$.datasource.jsonData.derivedFields.2.url', |
||||
position: { |
||||
start: 0, |
||||
end: 14, |
||||
}, |
||||
format: 'raw', |
||||
}, |
||||
], |
||||
}, |
||||
properties: { |
||||
refId: 'A', |
||||
datasource: { |
||||
type: 'loki', |
||||
uid: 'loki-uid', |
||||
}, |
||||
queryType: 'range', |
||||
editorMode: 'code', |
||||
expr: '{test="test"}', |
||||
}, |
||||
}, |
||||
], |
||||
}, |
||||
}, |
||||
], |
||||
}); |
||||
@ -0,0 +1,26 @@ |
||||
import { DataQuery } from '@grafana/schema/dist/esm/index'; |
||||
|
||||
export type DataQueryTarget = { |
||||
variables: object; // TODO: Detect variables in #86838
|
||||
properties: DataQuery; |
||||
}; |
||||
|
||||
export type DataQuerySpec = { |
||||
apiVersion: string; |
||||
kind: string; |
||||
metadata: { |
||||
generateName: string; |
||||
name?: string; |
||||
creationTimestamp?: string; |
||||
}; |
||||
spec: { |
||||
title: string; |
||||
vars: object[]; // TODO: Detect variables in #86838
|
||||
targets: DataQueryTarget[]; |
||||
}; |
||||
}; |
||||
|
||||
export type DataQuerySpecResponse = { |
||||
apiVersion: string; |
||||
items: DataQuerySpec[]; |
||||
}; |
||||
@ -0,0 +1,17 @@ |
||||
/** |
||||
* This is a temporary place for Query Library API and data types. |
||||
* To be exposed via grafana-runtime/data in the future. |
||||
* |
||||
* Query Library is an experimental feature, the API and components are subject to change |
||||
* |
||||
* @alpha |
||||
*/ |
||||
|
||||
import { queryLibraryApi } from './api/factory'; |
||||
import { mockData } from './api/mocks'; |
||||
|
||||
export const { useAllQueryTemplatesQuery } = queryLibraryApi; |
||||
|
||||
export const QueryLibraryMocks = { |
||||
data: mockData, |
||||
}; |
||||
@ -0,0 +1,8 @@ |
||||
import { DataQuery } from '@grafana/schema'; |
||||
|
||||
export type QueryTemplate = { |
||||
uid: string; |
||||
title: string; |
||||
targets: DataQuery[]; |
||||
createdAtTimestamp: number; |
||||
}; |
||||
Loading…
Reference in new issue