Query Library: Enhance user data (#94231)

* Enhance user data

* Remove irrrelevant logic outside of try block

* Encode user uid for url
This commit is contained in:
Haris Rozajac
2024-10-08 07:12:33 -06:00
committed by GitHub
parent c6387854c5
commit ee65f89533
16 changed files with 173 additions and 62 deletions

View File

@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import { uniqBy } from 'lodash';
import { compact, uniq, uniqBy } from 'lodash';
import { useEffect, useMemo, useState } from 'react';
import { AppEvents, GrafanaTheme2, SelectableValue } from '@grafana/data';
@ -8,6 +8,7 @@ import { EmptyState, FilterInput, InlineLabel, MultiSelect, Spinner, useStyles2,
import { t, Trans } from 'app/core/internationalization';
import { createQueryText } from 'app/core/utils/richHistory';
import { useAllQueryTemplatesQuery } from 'app/features/query-library';
import { getUserInfo } from 'app/features/query-library/api/user';
import { QueryTemplate } from 'app/features/query-library/types';
import { getDatasourceSrv } from '../../plugins/datasource_srv';
@ -26,6 +27,8 @@ export function QueryTemplatesList(props: QueryTemplatesListProps) {
const [datasourceFilters, setDatasourceFilters] = useState<Array<SelectableValue<string>>>(
props.activeDatasources?.map((ds) => ({ value: ds, label: ds })) || []
);
const [userData, setUserData] = useState<string[]>([]);
const [userFilters, setUserFilters] = useState<Array<SelectableValue<string>>>([]);
const [allQueryTemplateRows, setAllQueryTemplateRows] = useState<QueryTemplateRow[]>([]);
const [isRowsLoading, setIsRowsLoading] = useState(true);
@ -40,6 +43,26 @@ export function QueryTemplatesList(props: QueryTemplatesListProps) {
return;
}
let userDataList;
const userQtList = uniq(compact(data.map((qt) => qt.user?.uid)));
const usersParam = userQtList.map((userUid) => `key=${encodeURIComponent(userUid)}`).join('&');
try {
userDataList = await getUserInfo(`?${usersParam}`);
} catch (error) {
getAppEvents().publish({
type: AppEvents.alertError.name,
payload: [
t('query-library.user-info-get-error', 'Error attempting to get user info from the library: {{error}}', {
error: JSON.stringify(error),
}),
],
});
setIsRowsLoading(false);
return;
}
setUserData(userDataList.display.map((user) => user.displayName));
const rowsPromises = data.map(async (queryTemplate: QueryTemplate, index: number) => {
try {
const datasourceRef = queryTemplate.targets[0]?.datasource;
@ -48,6 +71,9 @@ export function QueryTemplatesList(props: QueryTemplatesListProps) {
const query = queryTemplate.targets[0];
const queryText = createQueryText(query, datasourceApi);
const datasourceName = datasourceApi?.name || '';
const extendedUserData = userDataList.display.find(
(user) => `${user?.identity.type}:${user?.identity.name}` === queryTemplate.user?.uid
);
return {
index: index.toString(),
@ -59,7 +85,11 @@ export function QueryTemplatesList(props: QueryTemplatesListProps) {
query,
queryText,
description: queryTemplate.title,
user: queryTemplate.user,
user: {
uid: queryTemplate.user?.uid || '',
displayName: extendedUserData?.displayName || '',
avatarUrl: extendedUserData?.avatarUrl || '',
},
};
} catch (error) {
getAppEvents().publish({
@ -97,9 +127,10 @@ export function QueryTemplatesList(props: QueryTemplatesListProps) {
searchQueryLibrary(
allQueryTemplateRows,
searchQuery,
datasourceFilters.map((f) => f.value || '')
datasourceFilters.map((f) => f.value || ''),
userFilters.map((f) => f.value || '')
),
[allQueryTemplateRows, searchQuery, datasourceFilters]
[allQueryTemplateRows, searchQuery, datasourceFilters, userFilters]
);
const datasourceNames = useMemo(() => {
@ -157,6 +188,22 @@ export function QueryTemplatesList(props: QueryTemplatesListProps) {
placeholder={'Filter queries for data sources(s)'}
aria-label={'Filter queries for data sources(s)'}
/>
<InlineLabel className={styles.label} width="auto">
<Trans i18nKey="query-library.user-names">User name(s):</Trans>
</InlineLabel>
<MultiSelect
className={styles.multiSelect}
onChange={(items, actionMeta) => {
setUserFilters(items);
actionMeta.action === 'select-option' && queryLibraryTrackFilterDatasource();
}}
value={userFilters}
options={userData.map((r) => {
return { value: r, label: r };
})}
placeholder={'Filter queries for user name(s)'}
aria-label={'Filter queries for user name(s)'}
/>
</Stack>
<QueryTemplatesTable queryTemplateRows={queryTemplateRows} />
</>

View File

@ -1,14 +1,20 @@
import { Avatar } from '@grafana/ui';
import { User } from 'app/features/query-library/types';
import { useQueryLibraryListStyles } from './styles';
type AddedByCellProps = {
user?: string;
user?: User;
};
export function AddedByCell(props: AddedByCellProps) {
const styles = useQueryLibraryListStyles();
return (
<div>
<span className={styles.otherText}>{props.user || 'Unknown'}</span>
<span className={styles.logo}>
<Avatar src={props.user?.avatarUrl || 'https://secure.gravatar.com/avatar'} alt="unknown" />
</span>
<span className={styles.otherText}>{props.user?.displayName || 'Unknown'}</span>
</div>
);
}

View File

@ -1,4 +1,5 @@
import { DataQuery, DataSourceRef } from '@grafana/schema';
import { User } from 'app/features/query-library/types';
export type QueryTemplateRow = {
index: string;
@ -9,6 +10,6 @@ export type QueryTemplateRow = {
datasourceRef?: DataSourceRef | null;
datasourceType?: string;
createdAtTimestamp?: number;
user?: string;
user?: User;
uid?: string;
};

View File

@ -1,15 +1,23 @@
import { QueryTemplateRow } from '../QueryTemplatesTable/types';
export const searchQueryLibrary = (queryLibrary: QueryTemplateRow[], query: string, filter: string[]) => {
export const searchQueryLibrary = (
queryLibrary: QueryTemplateRow[],
query: string,
dsFilters: string[],
userNameFilters: string[]
) => {
const result = queryLibrary.filter((item) => {
const matchesFilter =
filter.length === 0 || filter.some((f) => item.datasourceName?.toLowerCase().includes(f.toLowerCase()));
const matchesDsFilter =
dsFilters.length === 0 || dsFilters.some((f) => item.datasourceName?.toLowerCase().includes(f.toLowerCase()));
const matchesUserNameFilter =
userNameFilters.length === 0 || userNameFilters.includes(item.user?.displayName || '');
return (
(item.datasourceName?.toLowerCase().includes(query.toLowerCase()) ||
item.datasourceType?.toLowerCase().includes(query.toLowerCase()) ||
item.description?.toLowerCase().includes(query.toLowerCase()) ||
item.queryText?.toLowerCase().includes(query.toLowerCase())) &&
matchesFilter
matchesDsFilter &&
matchesUserNameFilter
);
});
return result;

View File

@ -33,7 +33,7 @@ import { GrafanaContext } from 'app/core/context/GrafanaContext';
import { GrafanaRoute } from 'app/core/navigation/GrafanaRoute';
import { Echo } from 'app/core/services/echo/Echo';
import { setLastUsedDatasourceUID } from 'app/core/utils/explore';
import { QueryLibraryMocks } from 'app/features/query-library';
import { IdentityServiceMocks, QueryLibraryMocks } from 'app/features/query-library';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { configureStore } from 'app/store/configureStore';
@ -77,12 +77,12 @@ export function setupExplore(options?: SetupOptions): {
data.totalCount = 0;
} else if (req.url.startsWith('/api/query-history') && req.method === 'GET') {
data.result = options?.queryHistory || {};
} else if (req.url.startsWith(QueryLibraryMocks.data.all.url)) {
data = QueryLibraryMocks.data.all.response;
} else if (req.url.startsWith(QueryLibraryMocks.data.url)) {
data = QueryLibraryMocks.data.response;
}
return of({ data });
}),
get: jest.fn(),
get: jest.fn().mockResolvedValue(IdentityServiceMocks.data.response),
patch: jest.fn().mockRejectedValue(undefined),
post: jest.fn(),
put: jest.fn().mockRejectedValue(undefined),

View File

@ -1,18 +0,0 @@
import { parseCreatedByValue } from './mappers';
describe.skip('mappers', () => {
describe('parseCreatedByValue', () => {
it.each`
value | expected
${''} | ${undefined}
${'api-key:1'} | ${{ userId: 1 }}
${'service-account:1:admin'} | ${{ userId: 1, login: 'admin' }}
${'user:1:admin'} | ${{ userId: 1, login: 'admin' }}
${'anonymous:0'} | ${undefined}
${'render:0'} | ${undefined}
${':0'} | ${undefined}
`("parsing '$value' should be '$expected'", ({ value, expected }) => {
expect(parseCreatedByValue(value)).toEqual(expected);
});
});
});

View File

@ -5,28 +5,6 @@ import { AddQueryTemplateCommand, QueryTemplate } from '../types';
import { API_VERSION, QueryTemplateKinds } from './query';
import { CREATED_BY_KEY, DataQueryFullSpec, DataQuerySpecResponse, DataQueryTarget } from './types';
export const parseCreatedByValue = (value?: string) => {
// https://github.com/grafana/grafana/blob/main/pkg/services/user/identity.go#L194
/*if (value !== undefined && value !== '') {
const vals = value.split(':');
if (vals.length >= 2) {
if (vals[0] === 'anonymous' || vals[0] === 'render' || vals[0] === '') {
return undefined;
} else {
return {
userId: vals[1],
login: vals[2],
};
}
} else {
return undefined;
}
} else {
return undefined;
}*/
return !!value ? value : undefined;
};
export const convertDataQueryResponseToQueryTemplates = (result: DataQuerySpecResponse): QueryTemplate[] => {
if (!result.items) {
return [];
@ -37,7 +15,9 @@ export const convertDataQueryResponseToQueryTemplates = (result: DataQuerySpecRe
title: spec.spec.title,
targets: spec.spec.targets.map((target: DataQueryTarget) => target.properties),
createdAtTimestamp: new Date(spec.metadata.creationTimestamp || '').getTime(),
user: parseCreatedByValue(spec.metadata?.annotations?.[CREATED_BY_KEY]),
user: {
uid: spec.metadata.annotations?.[CREATED_BY_KEY] || '',
},
};
});
};

View File

@ -1,4 +1,5 @@
import { BASE_URL } from './query';
import { getIdentityDisplayList } from './testdata/identityDisplayList';
import { getTestQueryList } from './testdata/testQueryList';
export const mockData = {
@ -6,4 +7,7 @@ export const mockData = {
url: BASE_URL,
response: getTestQueryList(),
},
identityDisplay: {
response: getIdentityDisplayList(),
},
};

View File

@ -0,0 +1,26 @@
export const getIdentityDisplayList = () => ({
kind: 'DisplayList',
apiVersion: 'iam.grafana.app/v0alpha1',
metadata: {},
keys: ['user:u000000001', 'user:u000000002'],
display: [
{
identity: {
type: 'user',
name: 'u000000001',
},
displayName: 'Test User1',
avatarURL: '/avatar/46d229b033af06a191ff2267bca9ae56',
internalId: 1,
},
{
identity: {
type: 'user',
name: 'u000000002',
},
displayName: 'Test User2',
avatarURL: '/avatar/24c9e15e52afc47c225b757e7bee1f9d',
internalId: 2,
},
],
});

View File

@ -16,7 +16,7 @@ export const getTestQueryList = () => ({
uid: '65327fce-c545-489d-ada5-16f909453d12',
resourceVersion: '1783293341664808960',
creationTimestamp: '2024-04-25T20:32:58Z',
annotations: { 'grafana.app/createdBy': 'user:1:admin' },
annotations: { 'grafana.app/createdBy': 'user:u000000001' },
},
spec: {
title: 'Elastic Query Template',
@ -63,7 +63,7 @@ export const getTestQueryList = () => ({
uid: '3e71de65-efa7-40e3-8f23-124212cca455',
resourceVersion: '1783214217151647744',
creationTimestamp: '2024-04-25T11:05:55Z',
annotations: { 'grafana.app/createdBy': 'user:1:admin' },
annotations: { 'grafana.app/createdBy': 'user:u000000001' },
},
spec: {
title: 'Loki Query Template',

View File

@ -30,4 +30,29 @@ export type DataQuerySpecResponse = {
items: DataQueryFullSpec[];
};
// pkg/apis/iam/v0alpha1/types_display.go
export type UserDataQueryResponse = {
apiVersion: string;
kind: string;
metadata: {
selfLink: string;
resourceVersion: string;
continue: string;
remainingItemCount: number;
};
display: UserSpecResponse[];
keys: string[];
};
// pkg/apis/iam/v0alpha1/types_display.go
export type UserSpecResponse = {
avatarUrl: string;
displayName: string;
identity: {
name: string;
type: string;
};
internalId: number;
};
export const CREATED_BY_KEY = 'grafana.app/createdBy';

View File

@ -0,0 +1,18 @@
import { getBackendSrv, config } from '@grafana/runtime';
import { UserDataQueryResponse } from './types';
/**
* @alpha
*/
export const API_VERSION = 'iam.grafana.app/v0alpha1';
/**
* @alpha
*/
const BASE_URL = `apis/${API_VERSION}/namespaces/${config.namespace}/display`;
export async function getUserInfo(url?: string): Promise<UserDataQueryResponse> {
const userInfo = await getBackendSrv().get(`${BASE_URL}${url}`);
return userInfo;
}

View File

@ -24,5 +24,9 @@ export function isQueryLibraryEnabled() {
}
export const QueryLibraryMocks = {
data: mockData,
data: mockData.all,
};
export const IdentityServiceMocks = {
data: mockData.identityDisplay,
};

View File

@ -7,7 +7,7 @@ export type QueryTemplate = {
title: string;
targets: DataQuery[];
createdAtTimestamp: number;
user?: string;
user?: User;
};
export type AddQueryTemplateCommand = {
@ -23,3 +23,9 @@ export type EditQueryTemplateCommand = {
export type DeleteQueryTemplateCommand = {
uid: string;
};
export type User = {
uid: string;
displayName?: string;
avatarUrl?: string;
};

View File

@ -2218,7 +2218,9 @@
"datasource-names": "Datasource name(s):",
"delete-query-button": "Delete query",
"query-template-get-error": "Error attempting to get query template from the library: {{error}}",
"search": "Search by data source, query content or description"
"search": "Search by data source, query content or description",
"user-info-get-error": "Error attempting to get user info from the library: {{error}}",
"user-names": "User name(s):"
},
"query-operation": {
"header": {

View File

@ -2218,7 +2218,9 @@
"datasource-names": "Đäŧäşőūřčę ʼnämę(ş):",
"delete-query-button": "Đęľęŧę qūęřy",
"query-template-get-error": "Ēřřőř äŧŧęmpŧįʼnģ ŧő ģęŧ qūęřy ŧęmpľäŧę ƒřőm ŧĥę ľįþřäřy: {{error}}",
"search": "Ŝęäřčĥ þy đäŧä şőūřčę, qūęřy čőʼnŧęʼnŧ őř đęşčřįpŧįőʼn"
"search": "Ŝęäřčĥ þy đäŧä şőūřčę, qūęřy čőʼnŧęʼnŧ őř đęşčřįpŧįőʼn",
"user-info-get-error": "Ēřřőř äŧŧęmpŧįʼnģ ŧő ģęŧ ūşęř įʼnƒő ƒřőm ŧĥę ľįþřäřy: {{error}}",
"user-names": "Ůşęř ʼnämę(ş):"
},
"query-operation": {
"header": {