Compare commits

..
23 changed files with 228 additions and 250 deletions
@@ -13,7 +13,7 @@ import (
// schema is unexported to prevent accidental overwrites
var (
schemaReceiver = resource.NewSimpleSchema("notifications.alerting.grafana.app", "v0alpha1", NewReceiver(), &ReceiverList{}, resource.WithKind("Receiver"),
resource.WithPlural("receivers"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{resource.SelectableField{
resource.WithPlural("receivers"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{{
FieldSelector: "spec.title",
FieldValueFunc: func(o resource.Object) (string, error) {
cast, ok := o.(*Receiver)
@@ -790,8 +790,6 @@ VariableOption: {
text: string | [...string]
// Value of the option
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
}
// Query variable specification
@@ -794,8 +794,6 @@ VariableOption: {
text: string | [...string]
// Value of the option
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
}
// Query variable specification
@@ -301,8 +301,6 @@ var _ resource.ListObject = &DashboardList{}
// Copy methods for all subresource types
// DeepCopy creates a full deep copy of DashboardStatus
func (s *DashboardStatus) DeepCopy() *DashboardStatus {
cpy := &DashboardStatus{}
@@ -301,8 +301,6 @@ var _ resource.ListObject = &DashboardList{}
// Copy methods for all subresource types
// DeepCopy creates a full deep copy of DashboardStatus
func (s *DashboardStatus) DeepCopy() *DashboardStatus {
cpy := &DashboardStatus{}
@@ -794,8 +794,6 @@ VariableOption: {
text: string | [...string]
// Value of the option
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
}
// Query variable specification
@@ -1411,8 +1411,6 @@ type DashboardVariableOption struct {
Text DashboardStringOrArrayOfString `json:"text"`
// Value of the option
Value DashboardStringOrArrayOfString `json:"value"`
// Additional properties for multi-props variables
Properties map[string]string `json:"properties,omitempty"`
}
// NewDashboardVariableOption creates a new DashboardVariableOption object.
@@ -798,8 +798,6 @@ VariableOption: {
text: string | [...string]
// Value of the option
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
}
// Query variable specification
@@ -1414,8 +1414,6 @@ type DashboardVariableOption struct {
Text DashboardStringOrArrayOfString `json:"text"`
// Value of the option
Value DashboardStringOrArrayOfString `json:"value"`
// Additional properties for multi-props variables
Properties map[string]string `json:"properties,omitempty"`
}
// NewDashboardVariableOption creates a new DashboardVariableOption object.
File diff suppressed because one or more lines are too long
@@ -18,8 +18,6 @@ import (
v1beta1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
)
var ()
var appManifestData = app.ManifestData{
AppName: "folder",
Group: "folder.grafana.app",
@@ -1,174 +0,0 @@
import { useEffect, useState } from 'react';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import {
CustomVariableSupport,
DataQueryRequest,
DataQueryResponse,
QueryEditorProps,
Field,
DataFrame,
MetricFindValue,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { EditorMode, EditorRows, EditorRow, EditorField } from '@grafana/plugin-ui';
import { Combobox, ComboboxOption } from '@grafana/ui';
import { SqlQueryEditorLazy } from './components/QueryEditorLazy';
import { SqlDatasource } from './datasource/SqlDatasource';
import { applyQueryDefaults } from './defaults';
import { QueryFormat, type SQLQuery, type SQLOptions, type SQLQueryMeta } from './types';
type SQLVariableQuery = { query: string } & SQLQuery;
const refId = 'SQLVariableQueryEditor-VariableQuery';
export class SQLVariableSupport extends CustomVariableSupport<SqlDatasource, SQLQuery> {
constructor(readonly datasource: SqlDatasource) {
super();
}
editor = SQLVariablesQueryEditor;
query(request: DataQueryRequest<SQLQuery>): Observable<DataQueryResponse> {
if (request.targets.length < 1) {
throw new Error('no variable query found');
}
const updatedQuery = migrateVariableQuery(request.targets[0]);
return this.datasource.query({ ...request, targets: [updatedQuery] }).pipe(
map((d: DataQueryResponse) => {
const frames = d.data || [];
const metricFindValues = convertDataFramesToMetricFindValues(frames, updatedQuery.meta);
return { data: metricFindValues };
})
);
}
getDefaultQuery(): Partial<SQLQuery> {
return applyQueryDefaults({ refId, editorMode: EditorMode.Builder, format: QueryFormat.Table });
}
}
type SQLVariableQueryEditorProps = QueryEditorProps<SqlDatasource, SQLQuery, SQLOptions>;
const SQLVariablesQueryEditor = (props: SQLVariableQueryEditorProps) => {
const query = migrateVariableQuery(props.query);
return (
<>
<SqlQueryEditorLazy {...props} query={query} />
<FieldMapping {...props} query={query} />
</>
);
};
const FieldMapping = (props: SQLVariableQueryEditorProps) => {
const { query, datasource, onChange } = props;
const [choices, setChoices] = useState<ComboboxOption[]>([]);
useEffect(() => {
let isActive = true;
// eslint-disable-next-line
const subscription = datasource.query({ targets: [query] } as DataQueryRequest<SQLQuery>).subscribe({
next: (response) => {
if (!isActive) {
return;
}
const fieldNames = (response.data[0] || { fields: [] }).fields.map((f: Field) => f.name);
setChoices(fieldNames.map((f: Field) => ({ value: f, label: f })));
},
error: () => {
if (isActive) {
setChoices([]);
}
},
});
return () => {
isActive = false;
subscription.unsubscribe();
};
}, [datasource, query]);
const onMetaPropChange = <Key extends keyof SQLQueryMeta, Value extends SQLQueryMeta[Key]>(
key: Key,
value: Value,
meta = query.meta || {}
) => {
onChange({ ...query, meta: { ...meta, [key]: value } });
};
return (
<EditorRows>
<EditorRow>
<EditorField label={t('grafana-sql.components.query-meta.variables.valueField', 'Value Field')}>
<Combobox
isClearable
value={query.meta?.valueField}
onChange={(e) => onMetaPropChange('valueField', e?.value)}
width={40}
options={choices}
/>
</EditorField>
<EditorField label={t('grafana-sql.components.query-meta.variables.textField', 'Text Field')}>
<Combobox
isClearable
value={query.meta?.textField}
onChange={(e) => onMetaPropChange('textField', e?.value)}
width={40}
options={choices}
/>
</EditorField>
</EditorRow>
</EditorRows>
);
};
const migrateVariableQuery = (rawQuery: string | SQLQuery): SQLVariableQuery => {
if (typeof rawQuery !== 'string') {
return {
...rawQuery,
refId: rawQuery.refId || refId,
query: rawQuery.rawSql || '',
};
}
return {
...applyQueryDefaults({
refId,
rawSql: rawQuery,
editorMode: rawQuery ? EditorMode.Code : EditorMode.Builder,
}),
query: rawQuery,
};
};
const convertDataFramesToMetricFindValues = (frames: DataFrame[], meta?: SQLQueryMeta): MetricFindValue[] => {
if (!frames.length) {
throw new Error('no results found');
}
const frame = frames[0];
const fields = frame.fields;
if (fields.length < 1) {
throw new Error('no fields found in the response');
}
let textField = fields.find((f) => f.name === '__text');
let valueField = fields.find((f) => f.name === '__value');
if (meta?.textField) {
textField = fields.find((f) => f.name === meta.textField);
}
if (meta?.valueField) {
valueField = fields.find((f) => f.name === meta.valueField);
}
const resolvedTextField = textField || valueField || fields[0];
const resolvedValueField = valueField || textField || fields[0];
const results: MetricFindValue[] = [];
const rowCount = frame.length;
for (let i = 0; i < rowCount; i++) {
const text = String(resolvedTextField.values[i] ?? '');
const value = String(resolvedValueField.values[i] ?? '');
const properties: Record<string, string> = {};
for (const field of fields) {
properties[field.name] = String(field.values[i] ?? '');
}
results.push({ text, value, properties });
}
return results;
};
-1
View File
@@ -21,7 +21,6 @@ export { TLSSecretsConfig } from './components/configuration/TLSSecretsConfig';
export { useMigrateDatabaseFields } from './components/configuration/useMigrateDatabaseFields';
export { SqlQueryEditorLazy } from './components/QueryEditorLazy';
export type { QueryHeaderProps } from './components/QueryHeader';
export { SQLVariableSupport } from './SQLVariableSupport';
export { createSelectClause, haveColumns } from './utils/sql.utils';
export { applyQueryDefaults } from './defaults';
export { makeVariable } from './utils/testHelpers';
@@ -69,12 +69,6 @@
"placeholder-select-format": "Select format",
"run-query": "Run query"
},
"query-meta": {
"variables": {
"textField": "Text Field",
"valueField": "Value Field"
}
},
"query-toolbox": {
"content-hit-ctrlcmdreturn-to-run-query": "Hit CTRL/CMD+Return to run query",
"tooltip-collapse": "Collapse editor",
-3
View File
@@ -50,8 +50,6 @@ export enum QueryFormat {
Table = 'table',
}
export type SQLQueryMeta = { valueField?: string; textField?: string };
export interface SQLQuery extends DataQuery {
alias?: string;
format?: QueryFormat;
@@ -61,7 +59,6 @@ export interface SQLQuery extends DataQuery {
sql?: SQLExpression;
editorMode?: EditorMode;
rawQuery?: boolean;
meta?: SQLQueryMeta;
}
export interface NameValue {
@@ -1,5 +1,7 @@
import { HttpResponse, http } from 'msw';
import { mockTeamsMap } from '../../../../fixtures/teams';
const getDisplayMapping = () =>
http.get<{ namespace: string }>('/apis/iam.grafana.app/v0alpha1/namespaces/:namespace/display', ({ request }) => {
const url = new URL(request.url);
@@ -26,4 +28,76 @@ const getDisplayMapping = () =>
});
});
export default [getDisplayMapping()];
const listExternalGroupMappings = () =>
http.get<{ namespace: string }>('/apis/iam.grafana.app/v0alpha1/namespaces/:namespace/externalgroupmappings', () => {
const items = [];
for (const [teamName, data] of mockTeamsMap.entries()) {
for (const group of data.groups) {
items.push({
apiVersion: 'iam.grafana.app/v0alpha1',
kind: 'ExternalGroupMapping',
metadata: {
name: `mapping-${teamName}-${group.groupId}`,
creationTimestamp: new Date().toISOString(),
},
spec: {
externalGroupId: group.groupId,
teamRef: {
name: teamName,
},
},
});
}
}
return HttpResponse.json({ items });
});
const createExternalGroupMapping = () =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
http.post<{ namespace: string }, any>(
'/apis/iam.grafana.app/v0alpha1/namespaces/:namespace/externalgroupmappings',
async ({ request }) => {
const body = await request.json();
const teamName = body.spec.teamRef.name;
const groupId = body.spec.externalGroupId;
const teamData = mockTeamsMap.get(teamName);
if (teamData) {
teamData.groups.push({ groupId });
}
return HttpResponse.json({
...body,
metadata: {
name: `mapping-${teamName}-${groupId}`,
creationTimestamp: new Date().toISOString(),
...body.metadata,
},
});
}
);
const deleteExternalGroupMapping = () =>
http.delete<{ namespace: string; name: string }>(
'/apis/iam.grafana.app/v0alpha1/namespaces/:namespace/externalgroupmappings/:name',
({ params }) => {
const { name } = params;
for (const [teamName, data] of mockTeamsMap.entries()) {
const groupIndex = data.groups.findIndex((g) => `mapping-${teamName}-${g.groupId}` === name);
if (groupIndex !== -1) {
data.groups.splice(groupIndex, 1);
return HttpResponse.json({ status: 'Success' });
}
}
return HttpResponse.json({ status: 'Failure', message: 'Not found' }, { status: 404 });
}
);
export default [
getDisplayMapping(),
listExternalGroupMappings(),
createExternalGroupMapping(),
deleteExternalGroupMapping(),
];
@@ -47,7 +47,7 @@ export const getFormFieldsForSilence = (silence: Silence): SilenceFormFields =>
startsAt: interval.start.toISOString(),
endsAt: interval.end.toISOString(),
comment: silence.comment,
createdBy: silence.createdBy,
createdBy: isExpired ? contextSrv.user.name : silence.createdBy,
duration: intervalToAbbreviatedDurationString(interval),
isRegex: false,
matchers: silence.matchers?.map(matcherToMatcherField) || [],
@@ -284,7 +284,6 @@ function variableValueOptionsToVariableOptions(varState: MultiValueVariable['sta
value: String(o.value),
text: o.label,
selected: Array.isArray(varState.value) ? varState.value.includes(o.value) : varState.value === o.value,
...(o.properties && { properties: o.properties }),
}));
}
@@ -80,18 +80,18 @@ const buildLabelPath = (label: string) => {
return label.includes('.') || label.trim().includes(' ') ? `["${label}"]` : `.${label}`;
};
const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === 'object' && value !== null && !Array.isArray(value);
};
const getVariableValueProperties = (variable: TypedVariableModel): string[] => {
function collectFieldPaths(option: Record<string, unknown>, currentPath: string): string[] {
if (!('valuesFormat' in variable) || variable.valuesFormat !== 'json') {
return [];
}
function collectFieldPaths(option: Record<string, string>, currentPath: string) {
let paths: string[] = [];
for (const field in option) {
if (option.hasOwnProperty(field)) {
const newPath = `${currentPath}.${field}`;
const value = option[field];
if (isRecord(value)) {
if (typeof value === 'object' && value !== null) {
paths = [...paths, ...collectFieldPaths(value, newPath)];
}
paths.push(newPath);
@@ -100,23 +100,11 @@ const getVariableValueProperties = (variable: TypedVariableModel): string[] => {
return paths;
}
if ('valuesFormat' in variable && variable.valuesFormat === 'json') {
try {
return collectFieldPaths(JSON.parse(variable.query)[0], variable.name);
} catch {
return [];
}
try {
return collectFieldPaths(JSON.parse(variable.query)[0], variable.name);
} catch {
return [];
}
if ('options' in variable && Array.isArray(variable.options) && variable.options.length > 0) {
for (const opt of variable.options) {
if ('properties' in opt && isRecord(opt.properties) && Object.keys(opt.properties).length > 0) {
return collectFieldPaths(opt.properties, variable.name);
}
}
}
return [];
};
export const getPanelLinksVariableSuggestions = (): VariableSuggestion[] => [
@@ -1,6 +1,6 @@
import { render, screen, waitFor } from 'test/test-utils';
import { setBackendSrv } from '@grafana/runtime';
import { setBackendSrv, config } from '@grafana/runtime';
import { setupMockServer } from '@grafana/test-utils/server';
import { MOCK_TEAMS, MOCK_TEAM_GROUPS } from '@grafana/test-utils/unstable';
import { backendSrv } from 'app/core/services/backend_srv';
@@ -25,9 +25,8 @@ describe('TeamGroupSync', () => {
expect(await screen.findAllByRole('row')).toHaveLength(MOCK_TEAM_GROUPS.length + 1); // items plus table header
});
it('should call add group', async () => {
it('should add group', async () => {
const { user } = setup();
// Wait for the groups to load so the "Add group" button appears
await screen.findAllByRole('row');
await user.click(screen.getAllByRole('button', { name: /add group/i })[0]);
@@ -43,10 +42,54 @@ describe('TeamGroupSync', () => {
const { user } = setup();
const groupToRemove = MOCK_TEAM_GROUPS[0].groupId;
// Wait for group to be rendered
await screen.findByRole('row', { name: new RegExp(groupToRemove, 'i') });
// Remove group
await user.click(screen.getByRole('button', { name: `Remove group ${groupToRemove}` }));
await waitFor(() =>
expect(screen.queryByRole('row', { name: new RegExp(groupToRemove, 'i') })).not.toBeInTheDocument()
);
});
});
describe('TeamGroupSync with kubernetesExternalGroupMapping enabled', () => {
const originalFeatureToggles = { ...config.featureToggles };
const originalNamespace = config.namespace;
beforeAll(() => {
config.featureToggles.kubernetesExternalGroupMapping = true;
config.namespace = 'default';
});
afterAll(() => {
config.featureToggles = originalFeatureToggles;
config.namespace = originalNamespace;
});
it('should render groups table', async () => {
setup();
expect(await screen.findAllByRole('row')).toHaveLength(MOCK_TEAM_GROUPS.length + 1); // items plus table header
});
it('should add group', async () => {
const { user } = setup();
await screen.findAllByRole('row');
await user.click(screen.getAllByRole('button', { name: /add group/i })[0]);
expect(screen.getByRole('textbox', { name: /add external group/i })).toBeVisible();
await user.type(screen.getByRole('textbox', { name: /add external group/i }), 'new-group');
await user.click(screen.getAllByRole('button', { name: /add group/i })[1]);
expect(await screen.findByRole('row', { name: /new-group/i })).toBeInTheDocument();
});
it('should remove group', async () => {
const { user } = setup();
const groupToRemove = MOCK_TEAM_GROUPS[0].groupId;
await screen.findByRole('row', { name: new RegExp(groupToRemove, 'i') });
await user.click(screen.getByRole('button', { name: `Remove group ${groupToRemove}` }));
await waitFor(() =>
+11 -13
View File
@@ -1,12 +1,7 @@
import { css, cx } from '@emotion/css';
import { FormEventHandler, useState } from 'react';
import {
TeamGroupDto,
useAddTeamGroupApiMutation,
useGetTeamGroupsApiQuery,
useRemoveTeamGroupApiQueryMutation,
} from '@grafana/api-clients/rtkq/legacy';
import { TeamGroupDto } from '@grafana/api-clients/rtkq/legacy';
import { Trans, t } from '@grafana/i18n';
import { Input, Tooltip, Icon, Button, useTheme2, InlineField, InlineFieldRow, useStyles2 } from '@grafana/ui';
import { SlideDown } from 'app/core/components/Animations/SlideDown';
@@ -15,6 +10,8 @@ import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { UpgradeBox, UpgradeContent, UpgradeContentProps } from 'app/core/components/Upgrade/UpgradeBox';
import { highlightTrial } from 'app/features/admin/utils';
import { useAddExternalGroupMapping, useGetExternalGroupMappings, useRemoveExternalGroupMapping } from './hooks';
interface Props {
isReadOnly: boolean;
teamUid: string;
@@ -27,9 +24,9 @@ export const TeamGroupSync = ({ isReadOnly, teamUid }: Props) => {
const [newGroupId, setNewGroupId] = useState('');
const styles = useStyles2(getStyles);
const { data: groups = [] } = useGetTeamGroupsApiQuery({ teamId: teamUid });
const [addTeamGroup] = useAddTeamGroupApiMutation();
const [removeTeamGroup] = useRemoveTeamGroupApiQueryMutation();
const { data: groups = [] } = useGetExternalGroupMappings({ teamId: teamUid });
const [addTeamGroup] = useAddExternalGroupMapping();
const [removeTeamGroup] = useRemoveExternalGroupMapping();
const onToggleAdding = () => {
setIsAddBoxVisible(!isAddBoxVisible);
@@ -46,11 +43,12 @@ export const TeamGroupSync = ({ isReadOnly, teamUid }: Props) => {
setNewGroupId('');
};
const onRemoveGroup = async (groupId: string | undefined) => {
if (!groupId) {
const onRemoveGroup = async (group: TeamGroupDto) => {
if (!group.groupId) {
return;
}
await removeTeamGroup({ teamId: teamUid, groupId });
// group.uid is always defined here because it comes from the API
await removeTeamGroup({ teamId: teamUid, groupId: group.groupId, uid: group.uid! });
};
const isNewGroupValid = () => {
@@ -65,7 +63,7 @@ export const TeamGroupSync = ({ isReadOnly, teamUid }: Props) => {
<Button
size="sm"
variant="destructive"
onClick={() => onRemoveGroup(group.groupId)}
onClick={() => onRemoveGroup(group)}
disabled={isReadOnly}
aria-label={t('teams.team-group-sync.aria-label-remove', 'Remove group {{groupName}}', {
groupName: group.groupId,
+80
View File
@@ -1,6 +1,18 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { useEffect, useMemo } from 'react';
import {
useCreateExternalGroupMappingMutation,
useListExternalGroupMappingQuery,
useDeleteExternalGroupMappingMutation,
} from '@grafana/api-clients/rtkq/iam/v0alpha1';
import {
useAddTeamGroupApiMutation,
useGetTeamGroupsApiQuery,
useRemoveTeamGroupApiQueryMutation,
TeamGroupDto,
} from '@grafana/api-clients/rtkq/legacy';
import { config } from '@grafana/runtime';
import {
useSearchTeamsQuery as useLegacySearchTeamsQuery,
useCreateTeamMutation,
@@ -152,3 +164,71 @@ export const useCreateTeam = () => {
return [trigger, response] as const;
};
export const useGetExternalGroupMappings = (args: { teamId: string }) => {
const shouldUseAppPlatform = Boolean(config.featureToggles.kubernetesExternalGroupMapping);
const legacyResult = useGetTeamGroupsApiQuery(args, { skip: shouldUseAppPlatform });
const { data: newApiData, ...newApiRest } = useListExternalGroupMappingQuery({}, { skip: !shouldUseAppPlatform });
const groups: TeamGroupDto[] = useMemo(() => {
// FIXME: Consider using the search API which has sorting support
return (newApiData?.items || [])
.filter((item) => item.spec.teamRef.name === args.teamId)
.map((item) => ({
groupId: item.spec.externalGroupId,
uid: item.metadata.name,
}));
}, [newApiData, args.teamId]);
if (shouldUseAppPlatform) {
return {
...newApiRest,
data: groups,
};
}
return legacyResult;
};
export const useAddExternalGroupMapping = () => {
const legacyMutation = useAddTeamGroupApiMutation();
const [addNew, newResult] = useCreateExternalGroupMappingMutation();
const add = async (args: { teamId: string; teamGroupMapping: { groupId: string } }) => {
return addNew({
externalGroupMapping: {
metadata: {
generateName: 'external-group-mapping-',
},
spec: {
externalGroupId: args.teamGroupMapping.groupId,
teamRef: {
name: args.teamId,
},
},
},
});
};
if (!config.featureToggles.kubernetesExternalGroupMapping) {
return legacyMutation;
}
return [add, newResult] as const;
};
export const useRemoveExternalGroupMapping = () => {
const legacyMutation = useRemoveTeamGroupApiQueryMutation();
const [deleteMapping, deleteResult] = useDeleteExternalGroupMappingMutation();
const remove = async (args: { teamId: string; groupId: string; uid: string }) => {
return deleteMapping({ name: args.uid });
};
if (!config.featureToggles.kubernetesExternalGroupMapping) {
return legacyMutation;
}
return [remove, deleteResult] as const;
};
@@ -11,7 +11,6 @@ import {
SQLQuery,
SQLSelectableValue,
SqlDatasource,
SQLVariableSupport,
formatSQL,
} from '@grafana/sql';
@@ -26,7 +25,6 @@ export class PostgresDatasource extends SqlDatasource {
constructor(instanceSettings: DataSourceInstanceSettings<PostgresOptions>) {
super(instanceSettings);
this.variables = new SQLVariableSupport(this);
}
getQueryModel(target?: SQLQuery, templateSrv?: TemplateSrv, scopedVars?: ScopedVars): PostgresQueryModel {