diff --git a/packages/grafana-data/src/transformations/transformers.ts b/packages/grafana-data/src/transformations/transformers.ts index 3344a7fb450..05606a50a37 100644 --- a/packages/grafana-data/src/transformations/transformers.ts +++ b/packages/grafana-data/src/transformations/transformers.ts @@ -24,6 +24,7 @@ import { renameFieldsTransformer } from './transformers/rename'; import { renameByRegexTransformer } from './transformers/renameByRegex'; import { seriesToRowsTransformer } from './transformers/seriesToRows'; import { sortByTransformer } from './transformers/sortBy'; +import { transposeTransformer } from './transformers/transpose'; export const standardTransformers = { noopTransformer, @@ -55,4 +56,5 @@ export const standardTransformers = { groupingToMatrixTransformer, limitTransformer, groupToNestedTable, + transposeTransformer, }; diff --git a/packages/grafana-data/src/transformations/transformers/ids.ts b/packages/grafana-data/src/transformations/transformers/ids.ts index 06ec0cd6277..d429f298743 100644 --- a/packages/grafana-data/src/transformations/transformers/ids.ts +++ b/packages/grafana-data/src/transformations/transformers/ids.ts @@ -37,6 +37,7 @@ export enum DataTransformerID { limit = 'limit', partitionByValues = 'partitionByValues', timeSeriesTable = 'timeSeriesTable', + transpose = 'transpose', formatTime = 'formatTime', formatString = 'formatString', regression = 'regression', diff --git a/packages/grafana-data/src/transformations/transformers/transpose.test.ts b/packages/grafana-data/src/transformations/transformers/transpose.test.ts new file mode 100644 index 00000000000..e522b93e885 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/transpose.test.ts @@ -0,0 +1,241 @@ +import { DataTransformerConfig } from '@grafana/schema'; + +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { FieldType } from '../../types/dataFrame'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { transformDataFrame } from '../transformDataFrame'; + +import { DataTransformerID } from './ids'; +import { transposeTransformer, TransposeTransformerOptions } from './transpose'; + +describe('Transpose transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([transposeTransformer]); + }); + + it('should transpose full numeric values and keep numeric type', async () => { + const cfgA: DataTransformerConfig = { + id: DataTransformerID.transpose, + options: {}, + }; + const seriesA = toDataFrame({ + name: 'A', + fields: [ + { name: 'env', type: FieldType.string, values: ['dev', 'prod', 'staging', 'release', 'beta'] }, + { name: 'january', type: FieldType.number, values: [11, 12, 13, 14, 15] }, + { name: 'february', type: FieldType.number, values: [6, 7, 8, 9, 10] }, + { name: 'march', type: FieldType.number, values: [1, 2, 3, 4, 5] }, + ], + }); + await expect(transformDataFrame([cfgA], [seriesA])).toEmitValuesWith((received) => { + const result = received[0]; + expect(result[0].fields).toEqual([ + { + name: 'Field', + type: FieldType.string, + values: ['january', 'february', 'march'], + config: {}, + }, + { + name: 'Value', + labels: { env: 'dev' }, + type: FieldType.number, + values: [11, 6, 1], + config: {}, + }, + { + name: 'Value', + labels: { env: 'prod' }, + type: FieldType.number, + values: [12, 7, 2], + config: {}, + }, + { + name: 'Value', + labels: { env: 'staging' }, + type: FieldType.number, + values: [13, 8, 3], + config: {}, + }, + { + name: 'Value', + labels: { env: 'release' }, + type: FieldType.number, + values: [14, 9, 4], + config: {}, + }, + { + name: 'Value', + labels: { env: 'beta' }, + type: FieldType.number, + values: [15, 10, 5], + config: {}, + }, + ]); + }); + }); + + it('should transpose and use string field type', async () => { + const cfgB: DataTransformerConfig = { + id: DataTransformerID.transpose, + options: {}, + }; + const seriesB = toDataFrame({ + name: 'B', + fields: [ + { name: 'env', type: FieldType.string, values: ['dev', 'prod', 'staging', 'release', 'beta'] }, + { name: 'january', type: FieldType.number, values: [11, 12, 13, 14, 15] }, + { name: 'february', type: FieldType.number, values: [6, 7, 8, 9, 10] }, + { name: 'type', type: FieldType.string, values: ['metricA', 'metricB', 'metricC', 'metricD', 'metricE'] }, + ], + }); + await expect(transformDataFrame([cfgB], [seriesB])).toEmitValuesWith((received) => { + const result = received[0]; + expect(result[0].fields).toEqual([ + { + name: 'Field', + type: FieldType.string, + values: ['january', 'february', 'type'], + config: {}, + }, + { + name: 'Value', + labels: { env: 'dev' }, + type: FieldType.string, + values: ['11', '6', 'metricA'], + config: {}, + }, + { + name: 'Value', + labels: { env: 'prod' }, + type: FieldType.string, + values: ['12', '7', 'metricB'], + config: {}, + }, + { + name: 'Value', + labels: { env: 'staging' }, + type: FieldType.string, + values: ['13', '8', 'metricC'], + config: {}, + }, + { + name: 'Value', + labels: { env: 'release' }, + type: FieldType.string, + values: ['14', '9', 'metricD'], + config: {}, + }, + { + name: 'Value', + labels: { env: 'beta' }, + type: FieldType.string, + values: ['15', '10', 'metricE'], + config: {}, + }, + ]); + }); + }); + + it('should transpose and keep number types and add new headers', async () => { + const cfgC: DataTransformerConfig = { + id: DataTransformerID.transpose, + options: { + firstFieldName: 'NewField', + }, + }; + const seriesC = toDataFrame({ + name: 'C', + fields: [ + { name: 'A', type: FieldType.number, values: [1, 5] }, + { name: 'B', type: FieldType.number, values: [2, 6] }, + { name: 'C', type: FieldType.number, values: [3, 7] }, + { name: 'D', type: FieldType.number, values: [4, 8] }, + ], + }); + await expect(transformDataFrame([cfgC], [seriesC])).toEmitValuesWith((received) => { + const result = received[0]; + expect(result[0].fields).toEqual([ + { + name: 'NewField', + type: FieldType.string, + values: ['A', 'B', 'C', 'D'], + config: {}, + }, + { + name: 'Value', + labels: { row: 1 }, + type: FieldType.number, + values: [1, 2, 3, 4], + config: {}, + }, + { + name: 'Value', + labels: { row: 2 }, + type: FieldType.number, + values: [5, 6, 7, 8], + config: {}, + }, + ]); + }); + }); + + it('should transpose and handle different types and rename first element', async () => { + const cfgD: DataTransformerConfig = { + id: DataTransformerID.transpose, + options: { + firstFieldName: 'Field1', + }, + }; + const seriesD = toDataFrame({ + name: 'D', + fields: [ + { + name: 'time', + type: FieldType.time, + values: ['2024-06-10 08:30:00', '2024-06-10 08:31:00', '2024-06-10 08:32:00', '2024-06-10 08:33:00'], + }, + { name: 'value', type: FieldType.number, values: [1, 2, 3, 4] }, + ], + }); + await expect(transformDataFrame([cfgD], [seriesD])).toEmitValuesWith((received) => { + const result = received[0]; + expect(result[0].fields).toEqual([ + { + name: 'Field1', + type: FieldType.string, + values: ['value'], + config: {}, + }, + { + name: 'Value', + labels: { time: '2024-06-10 08:30:00' }, + type: FieldType.number, + values: [1], + config: {}, + }, + { + name: 'Value', + labels: { time: '2024-06-10 08:31:00' }, + type: FieldType.number, + values: [2], + config: {}, + }, + { + name: 'Value', + labels: { time: '2024-06-10 08:32:00' }, + type: FieldType.number, + values: [3], + config: {}, + }, + { + name: 'Value', + labels: { time: '2024-06-10 08:33:00' }, + type: FieldType.number, + values: [4], + config: {}, + }, + ]); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/transpose.ts b/packages/grafana-data/src/transformations/transformers/transpose.ts new file mode 100644 index 00000000000..b4e04c9d316 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/transpose.ts @@ -0,0 +1,105 @@ +import { map } from 'rxjs/operators'; + +import { DataFrame, Field, FieldType } from '../../types/dataFrame'; +import { DataTransformerInfo } from '../../types/transformations'; + +import { DataTransformerID } from './ids'; + +export interface TransposeTransformerOptions { + firstFieldName?: string; + restFieldsName?: string; +} + +export const transposeTransformer: DataTransformerInfo = { + id: DataTransformerID.transpose, + name: 'Transpose', + description: 'Transpose the data frame', + defaultOptions: {}, + + operator: (options) => (source) => + source.pipe( + map((data) => { + if (data.length === 0) { + return data; + } + return transposeDataFrame(options, data); + }) + ), +}; + +function transposeDataFrame(options: TransposeTransformerOptions, data: DataFrame[]): DataFrame[] { + return data.map((frame) => { + const firstField = frame.fields[0]; + const firstName = !options.firstFieldName ? 'Field' : options.firstFieldName; + const restName = !options.restFieldsName ? 'Value' : options.restFieldsName; + const useFirstFieldAsHeaders = + firstField.type === FieldType.string || firstField.type === FieldType.time || firstField.type === FieldType.enum; + const headers = useFirstFieldAsHeaders + ? [firstName, ...fieldValuesAsStrings(firstField, firstField.values)] + : [firstName, ...firstField.values.map((_, i) => restName)]; + const rows = useFirstFieldAsHeaders + ? frame.fields.map((field) => field.name).slice(1) + : frame.fields.map((field) => field.name); + const fieldType = determineFieldType( + useFirstFieldAsHeaders + ? frame.fields.map((field) => field.type).slice(1) + : frame.fields.map((field) => field.type) + ); + + const newFields = headers.map((fieldName, index) => { + if (index === 0) { + return { + name: firstName, + type: FieldType.string, + config: {}, + values: rows, + }; + } + + const values = frame.fields.map((field) => { + if (fieldType === FieldType.string) { + return fieldValuesAsStrings(field, [field.values[index - 1]])[0]; + } + return field.values[index - 1]; + }); + + const labelName = useFirstFieldAsHeaders ? firstField.name : 'row'; + const labelValue = useFirstFieldAsHeaders ? fieldName : index; + + return { + name: useFirstFieldAsHeaders ? restName : fieldName, + labels: { + [labelName]: labelValue, + }, + type: fieldType, + config: {}, + values: useFirstFieldAsHeaders ? values.slice(1) : values, + }; + }); + return { + ...frame, + fields: newFields, + length: Math.max(...newFields.map((field) => field.values.length)), + }; + }); +} + +function determineFieldType(fieldTypes: FieldType[]): FieldType { + const uniqueFieldTypes = new Set(fieldTypes); + return uniqueFieldTypes.size === 1 ? [...uniqueFieldTypes][0] : FieldType.string; +} + +function fieldValuesAsStrings(field: Field, values: unknown[]) { + switch (field.type) { + case FieldType.time: + case FieldType.number: + case FieldType.boolean: + case FieldType.string: + return values.map((v) => `${v}`); + case FieldType.enum: + // @ts-ignore + return values.map((v) => field.config.type!.enum!.text![v]); + default: + return values.map((v) => JSON.stringify(v)); + } +} diff --git a/public/app/features/transformers/docs/content.ts b/public/app/features/transformers/docs/content.ts index d03f6aa4efc..a632dd804b0 100644 --- a/public/app/features/transformers/docs/content.ts +++ b/public/app/features/transformers/docs/content.ts @@ -8,7 +8,7 @@ Browse to http://localhost:3003/docs/grafana/latest/panels-visualizations/query-transform-data/transform-data/ - Refer to ./docs/README.md for more information about building docs. + Refer to ./docs/README.md for more information about building docs. */ interface Link { @@ -286,8 +286,8 @@ This transformation allows you to extract and format data in various ways. You c name: 'Lookup fields from resource', getHelperDocs: function () { return ` -Use this transformation to enrich a field value by looking up additional fields from an external source. - +Use this transformation to enrich a field value by looking up additional fields from an external source. + This transformation has the following fields: - **Field** - Select a text field from your dataset. @@ -436,7 +436,7 @@ Use this transformation to selectively remove parts of your query results. There #### Use a regular expression -When you filter using a regular expression, field names that match the regular expression are included. +When you filter using a regular expression, field names that match the regular expression are included. For example, from the input data: @@ -637,7 +637,7 @@ Use this transformation to construct a matrix by specifying fields from your que getHelperDocs: function (imageRenderType: ImageRenderType = ImageRenderType.ShortcodeFigure) { return ` Use this transformation to group the data by a specified field (column) value and process calculations on each group. Records are generated that share the same grouped field value, to be displayed in a nested table. - + To calculate a statistic for a field, click the selection box next to it and select the **Calculate** option: ${buildImageContent( diff --git a/public/app/features/transformers/editors/TransposeTransformerEditor.tsx b/public/app/features/transformers/editors/TransposeTransformerEditor.tsx new file mode 100644 index 00000000000..4190d385e48 --- /dev/null +++ b/public/app/features/transformers/editors/TransposeTransformerEditor.tsx @@ -0,0 +1,45 @@ +import { + DataTransformerID, + standardTransformers, + TransformerRegistryItem, + TransformerUIProps, + TransformerCategory, +} from '@grafana/data'; +import { TransposeTransformerOptions } from '@grafana/data/src/transformations/transformers/transpose'; +import { InlineField, InlineFieldRow, Input } from '@grafana/ui'; + +export const TransposeTransfomerEditor = ({ options, onChange }: TransformerUIProps) => { + return ( + <> + + + onChange({ ...options, firstFieldName: e.currentTarget.value })} + width={25} + /> + + + + + onChange({ ...options, restFieldsName: e.currentTarget.value })} + width={25} + /> + + + + ); +}; + +export const transposeTransformerRegistryItem: TransformerRegistryItem = { + id: DataTransformerID.transpose, + editor: TransposeTransfomerEditor, + transformation: standardTransformers.transposeTransformer, + name: standardTransformers.transposeTransformer.name, + description: standardTransformers.transposeTransformer.description, + categories: new Set([TransformerCategory.Reformat]), +}; diff --git a/public/app/features/transformers/standardTransformers.ts b/public/app/features/transformers/standardTransformers.ts index 04f45d20e21..2fbc9fce5db 100644 --- a/public/app/features/transformers/standardTransformers.ts +++ b/public/app/features/transformers/standardTransformers.ts @@ -24,6 +24,7 @@ import { reduceTransformRegistryItem } from './editors/ReduceTransformerEditor'; import { renameByRegexTransformRegistryItem } from './editors/RenameByRegexTransformer'; import { seriesToRowsTransformerRegistryItem } from './editors/SeriesToRowsTransformerEditor'; import { sortByTransformRegistryItem } from './editors/SortByTransformerEditor'; +import { transposeTransformerRegistryItem } from './editors/TransposeTransformerEditor'; import { extractFieldsTransformRegistryItem } from './extractFields/ExtractFieldsTransformerEditor'; import { joinByLabelsTransformRegistryItem } from './joinByLabels/JoinByLabelsTransformerEditor'; import { fieldLookupTransformRegistryItem } from './lookupGazetteer/FieldLookupTransformerEditor'; @@ -68,5 +69,6 @@ export const getStandardTransformers = (): Array> = ...(config.featureToggles.groupToNestedTableTransformation ? [groupToNestedTableTransformRegistryItem] : []), formatTimeTransformerRegistryItem, timeSeriesTableTransformRegistryItem, + transposeTransformerRegistryItem, ]; }; diff --git a/public/img/transformations/dark/transpose.svg b/public/img/transformations/dark/transpose.svg new file mode 100644 index 00000000000..ba9b8eb03fc --- /dev/null +++ b/public/img/transformations/dark/transpose.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/img/transformations/light/transpose.svg b/public/img/transformations/light/transpose.svg new file mode 100644 index 00000000000..3997edb0248 --- /dev/null +++ b/public/img/transformations/light/transpose.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js new file mode 100644 index 00000000000..3510a8c2fda --- /dev/null +++ b/public/mockServiceWorker.js @@ -0,0 +1,281 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const PACKAGE_VERSION = '2.4.10'; +const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423'; +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse'); +const activeClientIds = new Set(); + +self.addEventListener('install', function () { + self.skipWaiting(); +}); + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('message', async function (event) { + const clientId = event.source.id; + + if (!clientId || !self.clients) { + return; + } + + const client = await self.clients.get(clientId); + + if (!client) { + return; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }); + break; + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }); + break; + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId); + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }); + break; + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId); + break; + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId); + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId; + }); + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister(); + } + + break; + } + } +}); + +self.addEventListener('fetch', function (event) { + const { request } = event; + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return; + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return; + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return; + } + + // Generate unique request ID. + const requestId = crypto.randomUUID(); + event.respondWith(handleRequest(event, requestId)); +}); + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event); + const response = await getResponse(event, client, requestId); + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + (async function () { + const responseClone = response.clone(); + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + requestId, + isMockedResponse: IS_MOCKED_RESPONSE in response, + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + body: responseClone.body, + headers: Object.fromEntries(responseClone.headers.entries()), + }, + }, + [responseClone.body] + ); + })(); + } + + return response; +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId); + + if (client?.frameType === 'top-level') { + return client; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible'; + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id); + }); +} + +async function getResponse(event, client, requestId) { + const { request } = event; + + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = request.clone(); + + function passthrough() { + const headers = Object.fromEntries(requestClone.headers.entries()); + + // Remove internal MSW request header so the passthrough request + // complies with any potential CORS preflight checks on the server. + // Some servers forbid unknown request headers. + delete headers['x-msw-intention']; + + return fetch(requestClone, { headers }); + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough(); + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough(); + } + + // Notify the client that a request has been intercepted. + const requestBuffer = await request.arrayBuffer(); + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: requestBuffer, + keepalive: request.keepalive, + }, + }, + [requestBuffer] + ); + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data); + } + + case 'PASSTHROUGH': { + return passthrough(); + } + } + + return passthrough(); +} + +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error); + } + + resolve(event.data); + }; + + client.postMessage(message, [channel.port2].concat(transferrables.filter(Boolean))); + }); +} + +async function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error(); + } + + const mockedResponse = new Response(response.body, response); + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }); + + return mockedResponse; +}