mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-15 01:12:56 +03:00
console: Add the OpenTelemetry settings
PR-URL: https://github.com/hasura/graphql-engine-mono/pull/7665 GitOrigin-RevId: 9d150ec5137f8c2f8d1f0a921aa4a686720070a1
This commit is contained in:
parent
c6b28b56d1
commit
f5ea2e6b5a
@ -0,0 +1,18 @@
|
||||
// import type { ServerConfig } from '../../../../../../src/hooks/useServerConfig';
|
||||
|
||||
export const config /*: ServerConfig*/ = {
|
||||
version: 'v2.8.1',
|
||||
is_function_permissions_inferred: true,
|
||||
default_naming_convention: '',
|
||||
is_remote_schema_permissions_enabled: true,
|
||||
is_admin_secret_set: false,
|
||||
is_auth_hook_set: false,
|
||||
is_jwt_set: false,
|
||||
is_allow_list_enabled: false,
|
||||
experimental_features: [],
|
||||
jwt: {
|
||||
claims_namespace: '',
|
||||
claims_format: '',
|
||||
},
|
||||
is_prometheus_metrics_enabled: false,
|
||||
};
|
@ -0,0 +1,9 @@
|
||||
// import type { MetadataResponse } from '../../../../../../src/features/MetadataAPI/types';
|
||||
|
||||
export const export_metadata /*: MetadataResponse*/ = {
|
||||
resource_version: 0,
|
||||
metadata: {
|
||||
version: 3,
|
||||
sources: [],
|
||||
},
|
||||
};
|
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* At the time of writing, the get_catalog_state request is not typed at all in the Console.
|
||||
*/
|
||||
export const get_catalog_state = {
|
||||
id: 'b23fe106-4c3e-405f-9ca5-4aef77dec759',
|
||||
cli_state: {
|
||||
settings: { migration_mode: 'true' },
|
||||
isStateCopyCompleted: true,
|
||||
},
|
||||
console_state: {
|
||||
telemetryNotificationShown: true,
|
||||
onboardingShown: true,
|
||||
console_notifications: {
|
||||
admin: { read: [], showBadge: true, date: null },
|
||||
},
|
||||
},
|
||||
};
|
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* At the time of writing, the get_inconsistent_metadata request is not typed at all in the Console.
|
||||
*/
|
||||
export const get_inconsistent_metadata = {
|
||||
is_consistent: true,
|
||||
inconsistent_objects: [],
|
||||
};
|
@ -0,0 +1,41 @@
|
||||
import { config } from './config';
|
||||
import { version } from './version';
|
||||
import { export_metadata } from './export_metadata';
|
||||
import { get_catalog_state } from './get_catalog_state';
|
||||
import { get_inconsistent_metadata } from './get_inconsistent_metadata';
|
||||
|
||||
export function stubInitialServerRequests() {
|
||||
cy.log('**--- Stub all the initial requests**');
|
||||
cy.intercept('http://localhost:8080/v1/metadata', req => {
|
||||
if (req.body.type === 'export_metadata') {
|
||||
req.alias = 'export_metadata';
|
||||
req.reply(export_metadata);
|
||||
}
|
||||
|
||||
if (req.body.type === 'get_inconsistent_metadata') {
|
||||
req.alias = 'get_inconsistent_metadata';
|
||||
req.reply(get_inconsistent_metadata);
|
||||
}
|
||||
|
||||
if (req.body.type === 'get_catalog_state') {
|
||||
req.alias = 'get_catalog_state';
|
||||
req.reply(get_catalog_state);
|
||||
}
|
||||
});
|
||||
|
||||
cy.intercept('http://localhost:8080/v1alpha1/config', { body: config }).as(
|
||||
'config'
|
||||
);
|
||||
cy.intercept('http://localhost:8080/v1/version', { body: version }).as(
|
||||
'version'
|
||||
);
|
||||
}
|
||||
|
||||
export function waitForInitialServerRequests() {
|
||||
// In no particular order...
|
||||
cy.wait('@config');
|
||||
cy.wait('@version');
|
||||
cy.wait('@export_metadata');
|
||||
cy.wait('@get_catalog_state');
|
||||
cy.wait('@get_inconsistent_metadata');
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* At the time of writing, the version request is not typed at all in the Console.
|
||||
*/
|
||||
export const version = {
|
||||
version: 'dev-d9ed85c-alexopentelemetry-always-validate',
|
||||
};
|
@ -0,0 +1,45 @@
|
||||
import { testMode } from '../../../helpers/common';
|
||||
|
||||
import { logMetadataRequests } from './utils/requests/logMetadataRequests';
|
||||
import { openTelemetryMustNotExist } from './utils/testState/openTelemetryMustNotExist';
|
||||
|
||||
// NOTE: This test suite does not include cases for relationships, headers and the codegen part
|
||||
|
||||
if (testMode !== 'cli') {
|
||||
// Why this test is skipped?
|
||||
// - because OpenTelemetry is a "Pro Console" feature (it works with a EE Lite server)
|
||||
// - at the moment, we do not have E2E tests for the Pro Console
|
||||
// - anyway, from a server standpoint, an EE Lite server is available in CI and usable for the E2E tests
|
||||
// but these tests are fundamental to locally test the OpenTelemetry feature and being sure it works
|
||||
// properly
|
||||
describe.skip('OpenTelemetry', () => {
|
||||
beforeEach(() => {
|
||||
openTelemetryMustNotExist();
|
||||
logMetadataRequests();
|
||||
|
||||
cy.visit('/settings/opentelemetry');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
openTelemetryMustNotExist();
|
||||
});
|
||||
|
||||
it('When OpenTelemetry is set up, then everything should work', () => {
|
||||
cy.log('**--- STEP: Enable OpenTelemetry**');
|
||||
cy.findByLabelText('Status').click();
|
||||
|
||||
cy.log('**--- STEP: Type the Endpoint**');
|
||||
cy.findByLabelText('Endpoint', { selector: 'input' }).type(
|
||||
'http://example.io'
|
||||
);
|
||||
|
||||
cy.log('**--- STEP: Click the Submit button**');
|
||||
cy.findByRole('button', { name: 'Connect' }).click();
|
||||
|
||||
cy.log('**--- STEP: Check the success notification**');
|
||||
cy.expectSuccessNotificationWithMessage(
|
||||
'Successfully updated the OpenTelemetry Configuration'
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
@ -0,0 +1,208 @@
|
||||
import { z } from 'zod';
|
||||
import produce from 'immer';
|
||||
|
||||
import { blockServerRequests } from './utils/requests/blockServerRequests';
|
||||
|
||||
import {
|
||||
stubInitialServerRequests,
|
||||
waitForInitialServerRequests,
|
||||
} from './fixtures/initialRequests/stubInitialServerRequests';
|
||||
|
||||
import type {
|
||||
unexistingEnvVarSchema,
|
||||
SetOpenTelemetryQuery,
|
||||
hasuraEnvVarsNotAllowedSchema,
|
||||
} from '../../../../src/features/hasura-metadata-types';
|
||||
|
||||
import { export_metadata } from './fixtures/initialRequests/export_metadata';
|
||||
|
||||
// Why this test is skipped?
|
||||
// - because OpenTelemetry is a "Pro Console" feature (it works with a EE Lite server)
|
||||
// - at the moment, we do not have E2E tests for the Pro Console
|
||||
// but these tests are fundamental to locally test the OpenTelemetry feature and being sure it works
|
||||
// properly
|
||||
describe.skip('OpenTelemetry', () => {
|
||||
beforeEach(() => {
|
||||
cy.log('**--- Start controlling the server**');
|
||||
blockServerRequests();
|
||||
stubInitialServerRequests();
|
||||
|
||||
cy.log('**--- Load the Console**');
|
||||
cy.visit('/settings/opentelemetry');
|
||||
|
||||
waitForInitialServerRequests();
|
||||
});
|
||||
|
||||
it('When OpenTelemetry is set up, then everything should work', () => {
|
||||
cy.log('**--- STEP: Enable OpenTelemetry**');
|
||||
cy.findByLabelText('Status').click();
|
||||
|
||||
cy.log('**--- STEP: Type the Endpoint**');
|
||||
cy.findByLabelText('Endpoint', { selector: 'input' }).type(
|
||||
'http://example.io'
|
||||
);
|
||||
|
||||
cy.log(
|
||||
'**--- STEP: Intercept the set_opentelemetry_config request and the next export_metadata one**'
|
||||
);
|
||||
let openTelemetryFixture: SetOpenTelemetryQuery['args'] | undefined;
|
||||
cy.intercept('POST', 'http://localhost:8080/v1/metadata', req => {
|
||||
if (req.body.type === 'set_opentelemetry_config') {
|
||||
Cypress.log({
|
||||
message: '**--- STEP: Intercept the set_opentelemetry_config call**',
|
||||
});
|
||||
|
||||
req.alias = 'set_opentelemetry_config';
|
||||
|
||||
// Steal the server openTelemetry configuration from the one passed by the Console
|
||||
openTelemetryFixture = req.body.args;
|
||||
|
||||
const fixture: SetOpenTelemetryQuery['successResponse'] = {
|
||||
message: 'success',
|
||||
};
|
||||
req.reply(fixture);
|
||||
}
|
||||
|
||||
if (req.body.type === 'export_metadata') {
|
||||
Cypress.log({
|
||||
message: '**--- STEP: Intercept the export_metadata call**',
|
||||
});
|
||||
|
||||
req.alias = 'export_metadata_with_opentelemetry';
|
||||
|
||||
req.reply(
|
||||
// Use the openTelemetry configuration passed from the Console to the server to get back
|
||||
// a metadata that includes the same configuration
|
||||
produce(export_metadata, draft => {
|
||||
draft.metadata.opentelemetry = openTelemetryFixture;
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
cy.log('**--- STEP: Click the Submit button**');
|
||||
// Why update and not connect?
|
||||
cy.findByRole('button', { name: 'Connect' }).click();
|
||||
|
||||
cy.log('**--- STEP: Wait for the Console to save OpenTelemetry**');
|
||||
cy.wait('@set_opentelemetry_config');
|
||||
|
||||
cy.log('**--- STEP: Wait for the Console to refetch metadata**');
|
||||
cy.wait('@export_metadata_with_opentelemetry');
|
||||
|
||||
cy.log('**--- STEP: Check the success notification**');
|
||||
cy.expectSuccessNotificationWithMessage(
|
||||
'Successfully updated the OpenTelemetry Configuration'
|
||||
);
|
||||
});
|
||||
|
||||
it('When an unexisting env var is added, then the user should be prompted about it', () => {
|
||||
cy.log(
|
||||
'**--- STEP: Intercept the set_opentelemetry_config request and return the error**'
|
||||
);
|
||||
cy.intercept('POST', 'http://localhost:8080/v1/metadata', req => {
|
||||
if (req.body.type === 'set_opentelemetry_config') {
|
||||
Cypress.log({
|
||||
message: '**--- STEP: Intercept the set_opentelemetry_config call**',
|
||||
});
|
||||
|
||||
req.alias = 'set_opentelemetry_config';
|
||||
|
||||
const fixture: z.infer<typeof unexistingEnvVarSchema> = {
|
||||
// That's the only part of thr error the Console cares about
|
||||
internal: [
|
||||
{
|
||||
reason: `Inconsistent object: environment variable 'foo' not set`,
|
||||
},
|
||||
],
|
||||
};
|
||||
req.reply({
|
||||
statusCode: 400,
|
||||
body: fixture,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
cy.log('**--- STEP: Click the Submit button**');
|
||||
cy.findByRole('button', { name: 'Connect' }).click();
|
||||
|
||||
cy.log('**--- STEP: Wait for the Console to save OpenTelemetry**');
|
||||
cy.wait('@set_opentelemetry_config');
|
||||
|
||||
cy.log('**--- STEP: Check the error notification**');
|
||||
cy.expectErrorNotificationWithMessage(
|
||||
`Inconsistent object: environment variable 'foo' not set`
|
||||
);
|
||||
});
|
||||
|
||||
it('When an HASURA_GRAPHQL_ env var is added, then the user should be prompted about it', () => {
|
||||
cy.log(
|
||||
'**--- STEP: Intercept the set_opentelemetry_config request and return the error**'
|
||||
);
|
||||
cy.intercept('POST', 'http://localhost:8080/v1/metadata', req => {
|
||||
if (req.body.type === 'set_opentelemetry_config') {
|
||||
Cypress.log({
|
||||
message: '**--- STEP: Intercept the set_opentelemetry_config call**',
|
||||
});
|
||||
|
||||
req.alias = 'set_opentelemetry_config';
|
||||
|
||||
const fixture: z.infer<typeof hasuraEnvVarsNotAllowedSchema> = {
|
||||
// That's the only part of thr error the Console cares about
|
||||
|
||||
error:
|
||||
'env variables starting with "HASURA_GRAPHQL_" are not allowed in value_from_env: HASURA_GRAPHQL_ENABLED_APIS',
|
||||
};
|
||||
req.reply({
|
||||
statusCode: 400,
|
||||
body: fixture,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
cy.log('**--- STEP: Click the Submit button**');
|
||||
cy.findByRole('button', { name: 'Connect' }).click();
|
||||
|
||||
cy.log('**--- STEP: Wait for the Console to save OpenTelemetry**');
|
||||
cy.wait('@set_opentelemetry_config');
|
||||
|
||||
cy.log('**--- STEP: Check the error notification**');
|
||||
cy.expectErrorNotificationWithMessage(
|
||||
`env variables starting with "HASURA_GRAPHQL_" are not allowed in value_from_env: HASURA_GRAPHQL_ENABLED_APIS`
|
||||
);
|
||||
});
|
||||
|
||||
it('When an unexpected error is returned from the server, then the user should be prompted about it', () => {
|
||||
cy.log(
|
||||
'**--- STEP: Intercept the set_opentelemetry_config request and return the error**'
|
||||
);
|
||||
cy.intercept('POST', 'http://localhost:8080/v1/metadata', req => {
|
||||
if (req.body.type === 'set_opentelemetry_config') {
|
||||
Cypress.log({
|
||||
message: '**--- STEP: Intercept the set_opentelemetry_config call**',
|
||||
});
|
||||
|
||||
req.alias = 'set_opentelemetry_config';
|
||||
|
||||
const fixture = {
|
||||
unmanagedError: 'An error the Console does not manage',
|
||||
};
|
||||
req.reply({
|
||||
statusCode: 400,
|
||||
body: fixture,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
cy.log('**--- STEP: Click the Submit button**');
|
||||
cy.findByRole('button', { name: 'Connect' }).click();
|
||||
|
||||
cy.log('**--- STEP: Wait for the Console to save OpenTelemetry**');
|
||||
cy.wait('@set_opentelemetry_config');
|
||||
|
||||
cy.log('**--- STEP: Check the error notification**');
|
||||
cy.expectErrorNotificationWithMessage(
|
||||
`{"unmanagedError":"An error the Console does not manage"}`
|
||||
);
|
||||
});
|
||||
});
|
@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
"__version": "10.4.0"
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Ensure no requests hit the server, it's mostly a safe protection for the developers running
|
||||
* server-free tests that could miss some requests are actually hitting the real server, then
|
||||
* resulting in test flakiness when because of partial server stubbing.
|
||||
*/
|
||||
export function blockServerRequests() {
|
||||
cy.log('**--- Prevent any requests to hit the real server**');
|
||||
cy.intercept('http://localhost:8080/**', { forceNetworkError: true });
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
interface SingleMetadataRequest {
|
||||
type: string;
|
||||
// There are a lot of other fields, but tracking them is not important for the purpose of this module
|
||||
}
|
||||
|
||||
interface BulkMetadataRequest {
|
||||
type: 'bulk';
|
||||
args: SingleMetadataRequest[];
|
||||
}
|
||||
|
||||
type MetadataRequest = SingleMetadataRequest | BulkMetadataRequest;
|
||||
|
||||
/*
|
||||
* Log all the requests outgoing to the Metadata endpoint.
|
||||
* This is useful to have a glance of the requests that are going to the server.
|
||||
*/
|
||||
export function logMetadataRequests() {
|
||||
cy.intercept('POST', 'http://localhost:8080/v1/metadata', req => {
|
||||
const noArgs = !req.body.args;
|
||||
|
||||
if (noArgs) return;
|
||||
|
||||
const requestBody = req.body as MetadataRequest;
|
||||
|
||||
if (requestBody.type === 'bulk' || requestBody.type === 'concurrent_bulk') {
|
||||
const request = requestBody as BulkMetadataRequest;
|
||||
Cypress.log({ message: '*--- Bulk request*' });
|
||||
|
||||
request.args.forEach(arg =>
|
||||
Cypress.log({ message: `*--- Request: ${arg.type}*` })
|
||||
);
|
||||
} else {
|
||||
Cypress.log({ message: `*--- Request: ${requestBody.type}*` });
|
||||
}
|
||||
});
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Delete OpenTelemetry straight on the server.
|
||||
*/
|
||||
export function deleteOpenTelemetry() {
|
||||
Cypress.log({ message: '**--- OpenTelemetry delete: start**' });
|
||||
|
||||
return cy
|
||||
.request('POST', 'http://localhost:8080/v1/metadata', {
|
||||
type: 'set_opentelemetry_config',
|
||||
args: {},
|
||||
})
|
||||
.then(() => Cypress.log({ message: '**--- OpenTelemetry delete: end**' }));
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Read the Metadata straight from the server.
|
||||
*/
|
||||
export function readMetadata() {
|
||||
Cypress.log({ message: '**--- Metadata read: start**' });
|
||||
|
||||
return cy
|
||||
.request('POST', 'http://localhost:8080/v1/metadata', {
|
||||
args: {},
|
||||
type: 'export_metadata',
|
||||
})
|
||||
.then(_response => {
|
||||
Cypress.log({ message: '**--- Metadata read: end**' });
|
||||
});
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
import { readMetadata } from '../services/readMetadata';
|
||||
import { deleteOpenTelemetry } from '../services/deleteOpenTelemetry';
|
||||
|
||||
/**
|
||||
* Ensure the OpenTelemetry configuration does not exist.
|
||||
*/
|
||||
export function openTelemetryMustNotExist() {
|
||||
Cypress.log({ message: '**--- OpenTelemetry check: start**' });
|
||||
|
||||
readMetadata().then(response => {
|
||||
const openTelemetryExists = !!response.body.opentelemetry;
|
||||
|
||||
if (openTelemetryExists) {
|
||||
Cypress.log({ message: '**--- OpenTelemetry must be deleted**' });
|
||||
deleteOpenTelemetry();
|
||||
}
|
||||
});
|
||||
}
|
@ -85,6 +85,8 @@ export { ReactQueryProvider, reactQueryClient } from '../lib/lib/reactQuery';
|
||||
|
||||
export { PrometheusSettings } from '../lib/features/Prometheus';
|
||||
|
||||
export { OpenTelemetryFeature } from '../lib/features/OpenTelemetry';
|
||||
|
||||
export { FeatureFlags } from '../lib/features/FeatureFlags';
|
||||
|
||||
export {
|
||||
|
@ -48,6 +48,9 @@ class ErrorBoundary extends React.Component<
|
||||
override componentDidCatch(error: Error) {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
// ATTENTION: No need to setup anything for Sentry here, Sentry automatically tracks the error
|
||||
// caught from the error boundaries!
|
||||
|
||||
// for invalid path segment errors
|
||||
if (error instanceof NotFoundError) {
|
||||
this.setState({
|
||||
|
@ -2,6 +2,7 @@
|
||||
import React from 'react';
|
||||
import { Link, RouteComponentProps } from 'react-router';
|
||||
import { useServerConfig } from '@/hooks';
|
||||
import { useMetadata } from '@/features/MetadataAPI';
|
||||
import LeftContainer from '../../Common/Layout/LeftContainer/LeftContainer';
|
||||
import CheckIcon from '../../Common/Icons/Check';
|
||||
import CrossIcon from '../../Common/Icons/Cross';
|
||||
@ -37,6 +38,7 @@ type SectionDataKey =
|
||||
| 'inherited-roles'
|
||||
| 'insecure-domain'
|
||||
| 'prometheus-settings'
|
||||
| 'opentelemetry-settings'
|
||||
| 'feature-flags';
|
||||
|
||||
interface SectionData {
|
||||
@ -152,6 +154,11 @@ const Sidebar: React.FC<SidebarProps> = ({ location, metadata }) => {
|
||||
});
|
||||
}
|
||||
|
||||
const openTelemetryButton = useOpenTelemetryButton();
|
||||
if (isProLiteConsole(window.__env)) {
|
||||
sectionsData.push(openTelemetryButton);
|
||||
}
|
||||
|
||||
sectionsData.push({
|
||||
key: 'feature-flags',
|
||||
link: '/settings/feature-flags',
|
||||
@ -187,3 +194,31 @@ const Sidebar: React.FC<SidebarProps> = ({ location, metadata }) => {
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
|
||||
function useOpenTelemetryButton(): SectionData {
|
||||
const { data: openTelemetry } = useMetadata(m => m.metadata.opentelemetry);
|
||||
|
||||
const openTelemetryStatus = !openTelemetry
|
||||
? 'unknown'
|
||||
: openTelemetry.status === 'enabled'
|
||||
? 'enabled'
|
||||
: 'disabled';
|
||||
|
||||
const title = (
|
||||
<span>
|
||||
OpenTelemetry Exporter{' '}
|
||||
{openTelemetryStatus === 'enabled' ? (
|
||||
<CheckIcon className="ml-sm" />
|
||||
) : openTelemetryStatus === 'disabled' ? (
|
||||
<TimesCircleIcon className="ml-sm mb-1 h-5 w-5" />
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
|
||||
return {
|
||||
key: 'opentelemetry-settings',
|
||||
link: '/settings/opentelemetry',
|
||||
dataTestVal: 'opentelemetry-settings-link',
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
@ -36,7 +36,7 @@ interface CustomEventOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a custom event both in Heap and Sentry.
|
||||
* Track a custom event in Heap and breadcrumb in Sentry.
|
||||
*/
|
||||
export function trackCustomEvent(
|
||||
event: HeapEvent,
|
||||
|
@ -19,12 +19,18 @@ export type TMigration = {
|
||||
};
|
||||
};
|
||||
|
||||
type MetadataMigrationOptions = Omit<
|
||||
UseMutationOptions<Record<string, any>, Error, TMigration>,
|
||||
'mutationFn'
|
||||
> & {
|
||||
errorTransform?: (error: unknown) => unknown;
|
||||
};
|
||||
|
||||
export function useMetadataMigration(
|
||||
mutationOptions?: Omit<
|
||||
UseMutationOptions<Record<string, any>, Error, TMigration>,
|
||||
'mutationFn'
|
||||
>
|
||||
metadataMigrationOptions: MetadataMigrationOptions = {}
|
||||
) {
|
||||
const { errorTransform, ...mutationOptions } = metadataMigrationOptions;
|
||||
|
||||
const { mode } = useConsoleConfig();
|
||||
// Needed to avoid circular dependency
|
||||
const headers = useSelector<any>(state => state.tables.dataHeaders) as Record<
|
||||
@ -32,16 +38,21 @@ export function useMetadataMigration(
|
||||
string
|
||||
>;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(
|
||||
async props => {
|
||||
try {
|
||||
const { query } = props;
|
||||
const body = query;
|
||||
const result = await Api.post<RunSQLResponse>({
|
||||
url: Endpoints.metadata,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
const result = await Api.post<RunSQLResponse>(
|
||||
{
|
||||
url: Endpoints.metadata,
|
||||
headers,
|
||||
body,
|
||||
},
|
||||
undefined,
|
||||
errorTransform
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
@ -51,7 +62,7 @@ export function useMetadataMigration(
|
||||
{
|
||||
...mutationOptions,
|
||||
onSuccess: (data, variables, ctx) => {
|
||||
/*
|
||||
/*
|
||||
During console CLI mode, alert the CLI server to update it's local filesystem after metadata API call is successfull
|
||||
*/
|
||||
if (mode === CLI_CONSOLE_MODE) {
|
||||
@ -68,7 +79,7 @@ export function useMetadataMigration(
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
/*
|
||||
Get the latest metadata from server (this will NOT update metadata that is in the redux state, to do that please pass a custom onSuccess)
|
||||
*/
|
||||
queryClient.refetchQueries(['metadata'], { active: true });
|
||||
|
@ -2,9 +2,13 @@ import type { ComponentPropsWithoutRef } from 'react';
|
||||
import type { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
|
||||
import * as React from 'react';
|
||||
import { expect } from '@storybook/jest';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { waitFor, within } from '@storybook/testing-library';
|
||||
|
||||
import { OpenTelemetryConfig } from './OpenTelemetryConfig';
|
||||
import { defaultValues as defaultFormValues } from './components/Form/schema';
|
||||
import { OpenTelemetry } from './OpenTelemetry';
|
||||
|
||||
// --------------------------------------------------
|
||||
// NOT TESTED
|
||||
@ -14,9 +18,9 @@ import { OpenTelemetryConfig } from './OpenTelemetryConfig';
|
||||
// component is tested through the parent component.
|
||||
|
||||
export default {
|
||||
title: 'Features/OpenTelemetryConfig/OpenTelemetryConfig',
|
||||
component: OpenTelemetryConfig,
|
||||
} as ComponentMeta<typeof OpenTelemetryConfig>;
|
||||
title: 'Features/OpenTelemetry/OpenTelemetry',
|
||||
component: OpenTelemetry,
|
||||
} as ComponentMeta<typeof OpenTelemetry>;
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
@ -30,8 +34,8 @@ export default {
|
||||
// --------------------------------------------------
|
||||
// STORY DEFINITION
|
||||
// --------------------------------------------------
|
||||
export const Disabled: ComponentStory<typeof OpenTelemetryConfig> = args => {
|
||||
return <OpenTelemetryConfig {...args} />;
|
||||
export const Disabled: ComponentStory<typeof OpenTelemetry> = args => {
|
||||
return <OpenTelemetry {...args} />;
|
||||
};
|
||||
|
||||
Disabled.storyName = '💠 Disabled';
|
||||
@ -42,9 +46,11 @@ Disabled.storyName = '💠 Disabled';
|
||||
// Explicitly defining the story' args allows leveraging TS protection over them since story.args is
|
||||
// a Partial<Props> and then developers cannot know that they break the story by changing the
|
||||
// component props
|
||||
const disabledArgs: ComponentPropsWithoutRef<typeof OpenTelemetryConfig> = {
|
||||
updateOpenTelemetry: (...args) => {
|
||||
action('updateOpenTelemetry')(...args);
|
||||
const disabledArgs: ComponentPropsWithoutRef<typeof OpenTelemetry> = {
|
||||
isFirstTimeSetup: true,
|
||||
|
||||
setOpenTelemetry: (...args) => {
|
||||
action('setOpenTelemetry')(...args);
|
||||
|
||||
// Fake the server loading
|
||||
return new Promise(resolve => setTimeout(resolve, 1000));
|
||||
@ -69,7 +75,7 @@ Disabled.args = disabledArgs;
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// DISABLED STORY
|
||||
// ENABLED STORY
|
||||
// #region
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
@ -78,8 +84,8 @@ Disabled.args = disabledArgs;
|
||||
// --------------------------------------------------
|
||||
// STORY DEFINITION
|
||||
// --------------------------------------------------
|
||||
export const Enabled: ComponentStory<typeof OpenTelemetryConfig> = args => {
|
||||
return <OpenTelemetryConfig {...args} />;
|
||||
export const Enabled: ComponentStory<typeof OpenTelemetry> = args => {
|
||||
return <OpenTelemetry {...args} />;
|
||||
};
|
||||
|
||||
Enabled.storyName = '💠 Enabled';
|
||||
@ -90,9 +96,11 @@ Enabled.storyName = '💠 Enabled';
|
||||
// Explicitly defining the story' args allows leveraging TS protection over them since story.args is
|
||||
// a Partial<Props> and then developers cannot know that they break the story by changing the
|
||||
// component props
|
||||
const enabledArgs: ComponentPropsWithoutRef<typeof OpenTelemetryConfig> = {
|
||||
updateOpenTelemetry: (...args) => {
|
||||
action('updateOpenTelemetry')(...args);
|
||||
const enabledArgs: ComponentPropsWithoutRef<typeof OpenTelemetry> = {
|
||||
isFirstTimeSetup: true,
|
||||
|
||||
setOpenTelemetry: (...args) => {
|
||||
action('setOpenTelemetry')(...args);
|
||||
|
||||
// Fake the server loading
|
||||
return new Promise(resolve => setTimeout(resolve, 1000));
|
||||
@ -126,8 +134,8 @@ Enabled.args = enabledArgs;
|
||||
// --------------------------------------------------
|
||||
// STORY DEFINITION
|
||||
// --------------------------------------------------
|
||||
export const Skeleton: ComponentStory<typeof OpenTelemetryConfig> = args => {
|
||||
return <OpenTelemetryConfig {...args} />;
|
||||
export const Skeleton: ComponentStory<typeof OpenTelemetry> = args => {
|
||||
return <OpenTelemetry {...args} />;
|
||||
};
|
||||
|
||||
Skeleton.storyName = '💠 Skeleton';
|
||||
@ -138,8 +146,10 @@ Skeleton.storyName = '💠 Skeleton';
|
||||
// Explicitly defining the story' args allows leveraging TS protection over them since story.args is
|
||||
// a Partial<Props> and then developers cannot know that they break the story by changing the
|
||||
// component props
|
||||
const skeletonArgs: ComponentPropsWithoutRef<typeof OpenTelemetryConfig> = {
|
||||
updateOpenTelemetry: (...args) => {
|
||||
const skeletonArgs: ComponentPropsWithoutRef<typeof OpenTelemetry> = {
|
||||
isFirstTimeSetup: true,
|
||||
|
||||
setOpenTelemetry: (...args) => {
|
||||
action('updateOpenT')(...args);
|
||||
|
||||
// Fake the server loading
|
||||
@ -147,8 +157,101 @@ const skeletonArgs: ComponentPropsWithoutRef<typeof OpenTelemetryConfig> = {
|
||||
},
|
||||
|
||||
skeletonMode: true,
|
||||
metadataFormValues: undefined,
|
||||
metadataFormValues: defaultFormValues,
|
||||
};
|
||||
Skeleton.args = skeletonArgs;
|
||||
|
||||
// #endregion
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// DEFAULT VALUES TEST
|
||||
// #region
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
// --------------------------------------------------
|
||||
// STORY DEFINITION
|
||||
// --------------------------------------------------
|
||||
const loadingMetadataProps: ComponentPropsWithoutRef<typeof OpenTelemetry> = {
|
||||
skeletonMode: true,
|
||||
isFirstTimeSetup: false,
|
||||
setOpenTelemetry: () => Promise.resolve(),
|
||||
metadataFormValues: defaultFormValues,
|
||||
};
|
||||
|
||||
const metadataLoadedProps: ComponentPropsWithoutRef<typeof OpenTelemetry> = {
|
||||
skeletonMode: false,
|
||||
isFirstTimeSetup: false,
|
||||
|
||||
setOpenTelemetry: (...args) => {
|
||||
action('setOpenTelemetry')(...args);
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
metadataFormValues: {
|
||||
// Using non-default values
|
||||
enabled: true,
|
||||
batchSize: 99,
|
||||
dataType: ['traces'],
|
||||
connectionType: 'http/protobuf',
|
||||
endpoint: 'http://localhost:1234',
|
||||
headers: [{ name: 'foo', value: 'bar', type: 'from_value' }],
|
||||
attributes: [{ name: 'foo', value: 'bar', type: 'from_value' }],
|
||||
},
|
||||
};
|
||||
|
||||
export const DefaultValues: ComponentStory<never> = () => {
|
||||
// Initial placeholder props
|
||||
const [props, setProps] =
|
||||
useState<ComponentPropsWithoutRef<typeof OpenTelemetry>>(
|
||||
loadingMetadataProps
|
||||
);
|
||||
|
||||
// Simulate passing from the loading to the default state
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
setProps(metadataLoadedProps);
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, []);
|
||||
|
||||
return <OpenTelemetry {...props} />;
|
||||
};
|
||||
|
||||
DefaultValues.storyName =
|
||||
'🧪 Testing - When a configuration is available, must use it as the default values';
|
||||
|
||||
DefaultValues.parameters = {
|
||||
chromatic: { disableSnapshot: true },
|
||||
};
|
||||
|
||||
// --------------------------------------------------
|
||||
// INTERACTION TEST
|
||||
// --------------------------------------------------
|
||||
DefaultValues.play = async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
// STEP: Ensure the props used for the test are different from the default ones
|
||||
expect(metadataLoadedProps.metadataFormValues).not.toEqual(defaultFormValues);
|
||||
|
||||
// STEP: Wait until the metadata has been loaded (through waiting for the submit button being enabled)
|
||||
const submitButton = await canvas.findByRole('button', { name: 'Update' });
|
||||
await waitFor(() => {
|
||||
expect(submitButton).toBeEnabled();
|
||||
});
|
||||
|
||||
// STEP: check the default value of the Batch Size
|
||||
const batchSizeInputField = await canvas.findByLabelText('Batch Size', {
|
||||
selector: 'input',
|
||||
});
|
||||
expect(batchSizeInputField).toHaveValue(99);
|
||||
|
||||
// All the other input fields are not tested since if one input field has the correct default value
|
||||
// all of the other input fields have the correct default value.
|
||||
};
|
||||
|
||||
// #endregion
|
@ -0,0 +1,59 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { FormValues } from './components/Form/schema';
|
||||
import { defaultValues } from './components/Form/schema';
|
||||
|
||||
import { Form } from './components/Form/Form';
|
||||
import { Header } from './components/Header/Header';
|
||||
|
||||
interface OpenTelemetryProps {
|
||||
skeletonMode: boolean;
|
||||
isFirstTimeSetup: boolean;
|
||||
metadataFormValues: FormValues;
|
||||
setOpenTelemetry: (formValues: FormValues) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* All the OpenTelemetry page visual elements.
|
||||
*/
|
||||
export function OpenTelemetry(props: OpenTelemetryProps) {
|
||||
const {
|
||||
skeletonMode,
|
||||
isFirstTimeSetup,
|
||||
metadataFormValues,
|
||||
setOpenTelemetry,
|
||||
} = props;
|
||||
|
||||
const formValues = metadataFormValues || defaultValues;
|
||||
|
||||
let headerMode: 'enabled' | 'disabled' | 'skeleton' = 'enabled';
|
||||
|
||||
if (!metadataFormValues || !metadataFormValues.enabled)
|
||||
headerMode = 'disabled';
|
||||
|
||||
if (skeletonMode) headerMode = 'skeleton';
|
||||
|
||||
return (
|
||||
<div className="space-y-md max-w-screen-md p-md">
|
||||
{/*
|
||||
While the form is stateful and shows its own version of the OpenTelemetry config, the
|
||||
Header reflects the real OpenTelemetry config stored in the metadata. It means that when
|
||||
the users enable OpenTelemetry through the form toggle, the Header will show the "disabled"
|
||||
status because OpenTelemetry is not enabled until the users submit the form. Once submitted,
|
||||
and the metadata is invalidated and reloaded, then the Header will reflect the "enabled"
|
||||
status.
|
||||
*/}
|
||||
<Header mode={headerMode} />
|
||||
|
||||
<div>
|
||||
{/* This div avoid space-y-md separating too much the toggle and the form */}
|
||||
<Form
|
||||
onSubmit={setOpenTelemetry}
|
||||
defaultValues={formValues}
|
||||
skeletonMode={skeletonMode}
|
||||
firstTimeSetup={isFirstTimeSetup}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -12,7 +12,7 @@ import { defaultValues } from './schema';
|
||||
import { Form } from './Form';
|
||||
|
||||
export default {
|
||||
title: 'Features/OpenTelemetryConfig/Form',
|
||||
title: 'Features/OpenTelemetry/Form',
|
||||
component: Form,
|
||||
} as ComponentMeta<typeof Form>;
|
||||
|
||||
@ -50,6 +50,7 @@ Default.storyName = '💠 Default';
|
||||
const defaultStoryArgs: ComponentPropsWithoutRef<typeof Form> = {
|
||||
defaultValues,
|
||||
skeletonMode: false,
|
||||
firstTimeSetup: true,
|
||||
onSubmit: action('onSubmit'),
|
||||
};
|
||||
Default.args = defaultStoryArgs;
|
||||
@ -81,6 +82,7 @@ Skeleton.storyName = '💠 Skeleton';
|
||||
const skeletonStoryArgs: ComponentPropsWithoutRef<typeof Form> = {
|
||||
defaultValues,
|
||||
skeletonMode: true,
|
||||
firstTimeSetup: true,
|
||||
onSubmit: action('onSubmit'),
|
||||
};
|
||||
|
||||
@ -118,6 +120,7 @@ HappyPath.parameters = { chromatic: { disableSnapshot: true } };
|
||||
const happyPathStoryArgs: ComponentPropsWithoutRef<typeof Form> = {
|
||||
defaultValues,
|
||||
skeletonMode: false,
|
||||
firstTimeSetup: true,
|
||||
onSubmit: action('onSubmit'),
|
||||
};
|
||||
HappyPath.args = happyPathStoryArgs;
|
||||
@ -149,13 +152,9 @@ HappyPath.play = async ({ args, canvasElement }) => {
|
||||
// STEP: Open the collapsible headers section
|
||||
await userEvent.click(await canvas.findByText('Headers'));
|
||||
|
||||
// STEP: Check the first empty header is already there when the collapsible section is opened!
|
||||
expect(
|
||||
canvas.getByRole('textbox', { name: 'headers[0].name' })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
canvas.getByRole('textbox', { name: 'headers[0].value' })
|
||||
).toBeInTheDocument();
|
||||
const addNewHeaderButton = canvas.getByText('Add Headers');
|
||||
// STEP: Add one more header
|
||||
await userEvent.click(addNewHeaderButton);
|
||||
|
||||
await userEvent.type(
|
||||
canvas.getByRole('textbox', { name: 'headers[0].name' }),
|
||||
@ -167,8 +166,7 @@ HappyPath.play = async ({ args, canvasElement }) => {
|
||||
);
|
||||
|
||||
// STEP: Add one more header
|
||||
const addNewRowButton = canvas.getByText('Add Headers');
|
||||
await userEvent.click(addNewRowButton);
|
||||
await userEvent.click(addNewHeaderButton);
|
||||
|
||||
// STEP: Add an env-var header
|
||||
await userEvent.selectOptions(
|
||||
@ -187,8 +185,11 @@ HappyPath.play = async ({ args, canvasElement }) => {
|
||||
// STEP: Open the collapsible attributes section
|
||||
await userEvent.click(await canvas.findByText('Attributes'));
|
||||
|
||||
const addNewAttributeButton = canvas.getByText('Add Attributes');
|
||||
// STEP: Add one more attribute
|
||||
await userEvent.click(addNewAttributeButton);
|
||||
|
||||
// STEP: Add an attribute
|
||||
// ATTENTION: a first empty attributes should be already there when the collapsible section is opened!
|
||||
await userEvent.type(
|
||||
canvas.getByRole('textbox', { name: 'attributes[0].name' }),
|
||||
'foo'
|
||||
@ -199,7 +200,7 @@ HappyPath.play = async ({ args, canvasElement }) => {
|
||||
);
|
||||
|
||||
// STEP: Click the Submit button
|
||||
const submitButton = await canvas.findByRole('button', { name: 'Update' });
|
||||
const submitButton = await canvas.findByRole('button', { name: 'Connect' });
|
||||
await userEvent.click(submitButton);
|
||||
});
|
||||
|
||||
@ -226,8 +227,54 @@ HappyPath.play = async ({ args, canvasElement }) => {
|
||||
{ name: 'x-hasura-name', type: 'from_value', value: 'hasura_user' },
|
||||
{ name: 'x-hasura-env', type: 'from_env', value: 'HASURA_USER' },
|
||||
],
|
||||
attributes: [{ name: 'foo', type: 'from_value', value: 'bar' }],
|
||||
attributes: [{ name: 'foo', value: 'bar' }],
|
||||
});
|
||||
};
|
||||
|
||||
// #endregion
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// CONNECT BUTTON TEST
|
||||
// #region
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
// --------------------------------------------------
|
||||
// STORY DEFINITION
|
||||
// --------------------------------------------------
|
||||
export const ConnectButton: ComponentStory<typeof Form> = args => (
|
||||
<Form {...args} />
|
||||
);
|
||||
|
||||
ConnectButton.storyName = `🧪 Testing - When it's not the first-time setup, the button should have the text "Update"`;
|
||||
|
||||
ConnectButton.parameters = { chromatic: { disableSnapshot: true } };
|
||||
|
||||
// --------------------------------------------------
|
||||
// PROPS
|
||||
// --------------------------------------------------
|
||||
// Explicitly defining the story' args allows leveraging TS protection over them since story.args is
|
||||
// a Partial<Props> and then developers cannot know that they break the story by changing the
|
||||
// component props
|
||||
const connectButtonStoryArgs: ComponentPropsWithoutRef<typeof Form> = {
|
||||
defaultValues,
|
||||
skeletonMode: false,
|
||||
firstTimeSetup: false,
|
||||
onSubmit: action('onSubmit'),
|
||||
};
|
||||
ConnectButton.args = connectButtonStoryArgs;
|
||||
|
||||
// --------------------------------------------------
|
||||
// INTERACTION TEST
|
||||
// --------------------------------------------------
|
||||
ConnectButton.play = async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const updateButton = await canvas.getByRole('button', { name: 'Update' });
|
||||
expect(updateButton).toBeVisible();
|
||||
};
|
||||
|
||||
// #endregion
|
@ -12,13 +12,14 @@ import {
|
||||
import { RequestHeadersSelector } from '@/new-components/RequestHeadersSelector';
|
||||
|
||||
import type { FormValues } from './schema';
|
||||
|
||||
import { formSchema } from './schema';
|
||||
import { Toggle } from './components/Toggle';
|
||||
import { useResetDefaultFormValues } from './hooks/useResetDefaultFormValues';
|
||||
import { CollapsibleFieldWrapper } from './components/CollapsibleFieldWrapper';
|
||||
|
||||
interface FormProps {
|
||||
skeletonMode: boolean;
|
||||
firstTimeSetup: boolean;
|
||||
defaultValues: FormValues;
|
||||
onSubmit: (formValues: FormValues) => void;
|
||||
}
|
||||
@ -27,40 +28,50 @@ interface FormProps {
|
||||
* The form to update the OpenTelemetry configuration.
|
||||
*/
|
||||
export function Form(props: FormProps) {
|
||||
const { onSubmit, defaultValues, skeletonMode } = props;
|
||||
const { onSubmit, defaultValues, firstTimeSetup, skeletonMode } = props;
|
||||
|
||||
const {
|
||||
Form: ConsoleForm,
|
||||
methods: { control },
|
||||
methods: { control, reset },
|
||||
} = useConsoleForm({
|
||||
schema: formSchema,
|
||||
options: { defaultValues },
|
||||
});
|
||||
|
||||
useResetDefaultFormValues({ defaultValues, skeletonMode, reset });
|
||||
|
||||
const { isSubmitting } = useFormState({ control });
|
||||
const buttonTexts = firstTimeSetup
|
||||
? { text: 'Connect', loadingText: 'Connecting...' }
|
||||
: { text: 'Update', loadingText: 'Updating...' };
|
||||
|
||||
return (
|
||||
<ConsoleForm onSubmit={onSubmit}>
|
||||
<ConsoleForm
|
||||
onSubmit={data => {
|
||||
onSubmit(data);
|
||||
}}
|
||||
>
|
||||
<Toggle
|
||||
name="enabled"
|
||||
label="Status"
|
||||
writtenStatus={{ true: 'Enabled', false: 'Disabled' }}
|
||||
loading={skeletonMode}
|
||||
/>
|
||||
|
||||
{/* No need to redact the input fields since Heap avoid recording the input field values by default */}
|
||||
<InputField
|
||||
name="endpoint"
|
||||
label="Endpoint"
|
||||
placeholder="Your OpenTelemetry endpoint"
|
||||
tooltip="OpenTelemetry compliant receiver endpoint URL."
|
||||
knowMoreLink="https://hasura.io/docs/latest/enterprise/opentelemetry/#endpoint"
|
||||
clearButton
|
||||
loading={skeletonMode}
|
||||
/>
|
||||
|
||||
<Radio
|
||||
name="connectionType"
|
||||
label="Connection Type"
|
||||
tooltip="The protocol to be used for the communication with the receiver. At the moment, only HTTP is supported."
|
||||
knowMoreLink="https://hasura.io/docs/latest/enterprise/opentelemetry/#connection-type"
|
||||
options={[{ value: 'http/protobuf', label: 'HTTP' }]}
|
||||
loading={skeletonMode}
|
||||
// At the beginning, only one Connection Type is available, hence it does not make sense
|
||||
@ -73,6 +84,7 @@ export function Form(props: FormProps) {
|
||||
name="dataType"
|
||||
label="Data Type"
|
||||
tooltip="The type of observability data points to be exported. At the moment, only Traces is supported."
|
||||
knowMoreLink="https://hasura.io/docs/latest/enterprise/opentelemetry/#data-type"
|
||||
options={[{ value: 'traces', label: 'Traces' }]}
|
||||
loading={skeletonMode}
|
||||
// At the beginning, only one Data Type ia available, hence it does not make sense
|
||||
@ -80,50 +92,51 @@ export function Form(props: FormProps) {
|
||||
// TODO: replace it with readonly when the input fields support it
|
||||
disabled
|
||||
/>
|
||||
|
||||
<InputField
|
||||
name="batchSize"
|
||||
type="number"
|
||||
label="Batch Size"
|
||||
placeholder="A number between 1 and 512"
|
||||
tooltip="The maximum number of data points in an export request. The value should be between 1-512. Default value is 512."
|
||||
knowMoreLink="https://hasura.io/docs/latest/enterprise/opentelemetry/#batch-size"
|
||||
clearButton
|
||||
loading={skeletonMode}
|
||||
/>
|
||||
|
||||
<CollapsibleFieldWrapper
|
||||
inputFieldName="headers"
|
||||
label="Headers"
|
||||
tooltip="Additional custom headers added to export request."
|
||||
knowMoreLink="https://hasura.io/docs/latest/enterprise/opentelemetry/#headers"
|
||||
loading={skeletonMode}
|
||||
>
|
||||
{/* No need to redact the input fields since Heap avoid recording the input field values by default */}
|
||||
<RequestHeadersSelector name="headers" addButtonText="Add Headers" />
|
||||
</CollapsibleFieldWrapper>
|
||||
|
||||
<CollapsibleFieldWrapper
|
||||
inputFieldName="attributes"
|
||||
label="Attributes"
|
||||
tooltip="Additional custom tags added to export request."
|
||||
knowMoreLink="https://hasura.io/docs/latest/enterprise/opentelemetry/#attributes"
|
||||
loading={skeletonMode}
|
||||
>
|
||||
{/* No need to redact the input fields since Heap avoid recording the input field values by default */}
|
||||
<RequestHeadersSelector
|
||||
name="attributes"
|
||||
addButtonText="Add Attributes"
|
||||
typeSelect={false}
|
||||
/>
|
||||
</CollapsibleFieldWrapper>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
mode="primary"
|
||||
loadingText="Updating..."
|
||||
loadingText={buttonTexts.loadingText}
|
||||
isLoading={isSubmitting}
|
||||
// Skeleton mode
|
||||
disabled={skeletonMode}
|
||||
// Necessary to separate the button from the above skeleton
|
||||
className={clsx({ 'mt-1': skeletonMode })}
|
||||
>
|
||||
Update
|
||||
{buttonTexts.text}
|
||||
</Button>
|
||||
</ConsoleForm>
|
||||
);
|
@ -6,7 +6,7 @@ import * as React from 'react';
|
||||
import { CollapsibleFieldWrapper } from './CollapsibleFieldWrapper';
|
||||
|
||||
export default {
|
||||
title: 'Features/OpenTelemetryConfig/Form/CollapsibleFieldWrapper',
|
||||
title: 'Features/OpenTelemetry/Form/CollapsibleFieldWrapper',
|
||||
component: CollapsibleFieldWrapper,
|
||||
} as ComponentMeta<typeof CollapsibleFieldWrapper>;
|
||||
|
||||
@ -29,6 +29,7 @@ const storyArgs: ComponentPropsWithoutRef<typeof CollapsibleFieldWrapper> = {
|
||||
inputFieldName: 'Input Field Name',
|
||||
label: 'Input Field Label',
|
||||
tooltip: 'Input Field Tooltip',
|
||||
knowMoreLink: 'https://hasura.io/docs',
|
||||
children: <i>Children go here</i>,
|
||||
};
|
||||
Default.args = storyArgs;
|
@ -3,12 +3,14 @@ import Skeleton from 'react-loading-skeleton';
|
||||
|
||||
import { IconTooltip } from '@/new-components/Tooltip';
|
||||
import { Collapsible } from '@/new-components/Collapsible';
|
||||
import { KnowMoreLink } from '@/new-components/KnowMoreLink';
|
||||
|
||||
interface CollapsibleFieldWrapperProps {
|
||||
inputFieldName: string;
|
||||
label: string;
|
||||
tooltip: string;
|
||||
loading?: boolean;
|
||||
knowMoreLink?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -25,30 +27,30 @@ interface CollapsibleFieldWrapperProps {
|
||||
export const CollapsibleFieldWrapper: React.FC<
|
||||
CollapsibleFieldWrapperProps
|
||||
> = props => {
|
||||
const { inputFieldName, label, tooltip, children, loading } = props;
|
||||
const { inputFieldName, label, tooltip, children, loading, knowMoreLink } =
|
||||
props;
|
||||
|
||||
if (loading) return <Skeleton className="h-8" />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Collapsible
|
||||
triggerChildren={
|
||||
<>
|
||||
<label
|
||||
htmlFor={inputFieldName}
|
||||
className="block pt-1 text-gray-600 mb-xs pr-8 flex-grow220px"
|
||||
>
|
||||
<span className="flex items-center font-semibold">
|
||||
<span>{label}</span>
|
||||
<span className="font-normal ml-1">(Optional)</span>
|
||||
<IconTooltip message={tooltip} />
|
||||
</span>
|
||||
</label>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</>
|
||||
<Collapsible
|
||||
triggerChildren={
|
||||
<label
|
||||
htmlFor={inputFieldName}
|
||||
className="block pt-1 text-gray-600 mb-xs pr-8 flex-grow220px"
|
||||
>
|
||||
<span className="flex items-center ">
|
||||
<span className="font-semibold">{label}</span>
|
||||
<span className="ml-1">(Optional)</span>
|
||||
|
||||
{/* TODO: solve the "button inside a button" a11y problem */}
|
||||
<IconTooltip message={tooltip} />
|
||||
{!!knowMoreLink && <KnowMoreLink href={knowMoreLink} />}
|
||||
</span>
|
||||
</label>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
@ -11,7 +11,7 @@ import { userEvent, within } from '@storybook/testing-library';
|
||||
import { Toggle } from './Toggle';
|
||||
|
||||
export default {
|
||||
title: 'Features/OpenTelemetryConfig/Toggle',
|
||||
title: 'Features/OpenTelemetry/Toggle',
|
||||
component: Toggle,
|
||||
|
||||
parameters: {
|
@ -73,7 +73,7 @@ export const Toggle = <T extends z.infer<Schema>>({
|
||||
error={maybeError}
|
||||
renderDescriptionLineBreaks={renderDescriptionLineBreaks}
|
||||
>
|
||||
<div className={`flex ${className}`}>
|
||||
<div className={`flex ${className ?? ''}`}>
|
||||
<Switch
|
||||
id={name}
|
||||
aria-label={wrapperProps.label}
|
@ -0,0 +1,44 @@
|
||||
import type { UseFormReset } from 'react-hook-form';
|
||||
import { useRef, useEffect } from 'react';
|
||||
|
||||
import type { FormValues } from '../schema';
|
||||
|
||||
type Params = {
|
||||
skeletonMode: boolean;
|
||||
defaultValues: FormValues;
|
||||
reset: UseFormReset<FormValues>;
|
||||
};
|
||||
|
||||
/**
|
||||
* During the first metadata loading (aka when skeletonMode is true) the form is rendered but
|
||||
* covered by the skeletons. When the skeletons disappear, the metadata-based form values must take
|
||||
* place of the default (fake) ones. This could happen only through a form reset.
|
||||
*
|
||||
* Please note: this hook takes for granted that passing from skeletonMode=true to false means that
|
||||
* the metadata is loaded. Also, at the time of writing, the skeletonMode never returns true anymore.
|
||||
*/
|
||||
export function useResetDefaultFormValues(params: Params) {
|
||||
const { reset, defaultValues, skeletonMode } = params;
|
||||
|
||||
const latestParams = useRef({ defaultValues, reset });
|
||||
latestParams.current = { defaultValues, reset };
|
||||
|
||||
const prevSkeletonMode = useRef(skeletonMode);
|
||||
|
||||
// Detect skeletonMode passing from true to false
|
||||
useEffect(() => {
|
||||
const wasInSkeletonMode = prevSkeletonMode.current;
|
||||
const skeletonModeEnded = !skeletonMode;
|
||||
|
||||
const metadataLoadingIsCompleted = wasInSkeletonMode && skeletonModeEnded;
|
||||
|
||||
if (metadataLoadingIsCompleted) {
|
||||
latestParams.current.reset(latestParams.current.defaultValues);
|
||||
}
|
||||
}, [skeletonMode]);
|
||||
|
||||
// The order matters! Updating prevSkeletonMode must happen after consuming it!
|
||||
useEffect(() => {
|
||||
prevSkeletonMode.current = skeletonMode;
|
||||
}, [skeletonMode]);
|
||||
}
|
@ -73,9 +73,6 @@ export const defaultValues: FormValues = {
|
||||
|
||||
dataType: ['traces'],
|
||||
|
||||
// An empty element allows having the first line to be there when the collapsible opens.
|
||||
// TODO: the behavior should be encapsulated in the RequestHeadersSelector because it's hard to
|
||||
// maintain this UX when the server sends down the config without any headers...
|
||||
headers: [{ name: '', type: 'from_value', value: '' }],
|
||||
attributes: [{ name: '', type: 'from_value', value: '' }],
|
||||
headers: [],
|
||||
attributes: [],
|
||||
};
|
@ -6,7 +6,7 @@ import { BadgeDisabled as BadgeDisabledComponent } from './BadgeDisabled';
|
||||
import { BadgeSkeleton as BadgeSkeletonComponent } from './BadgeSkeleton';
|
||||
|
||||
export default {
|
||||
title: 'Features/OpenTelemetryConfig/Badge',
|
||||
title: 'Features/OpenTelemetry/Badge',
|
||||
};
|
||||
|
||||
// --------------------------------------------------
|
@ -4,7 +4,7 @@ import * as React from 'react';
|
||||
import { Header } from './Header';
|
||||
|
||||
export default {
|
||||
title: 'Features/OpenTelemetryConfig/Header',
|
||||
title: 'Features/OpenTelemetry/Header',
|
||||
};
|
||||
|
||||
// --------------------------------------------------
|
@ -6,7 +6,7 @@ import { HeroDisabled as HeroDisabledComponent } from './HeroDisabled';
|
||||
import { HeroSkeleton as HeroSkeletonComponent } from './HeroSkeleton';
|
||||
|
||||
export default {
|
||||
title: 'Features/OpenTelemetryConfig/Hero',
|
||||
title: 'Features/OpenTelemetry/Hero',
|
||||
};
|
||||
|
||||
// --------------------------------------------------
|
File diff suppressed because one or more lines are too long
@ -0,0 +1,15 @@
|
||||
import * as React from 'react';
|
||||
import { isProLiteConsole } from '@/utils';
|
||||
import { OpenTelemetryProvider } from './OpenTelemetryProvider/OpenTelemetryProvider';
|
||||
|
||||
export function OpenTelemetryFeature() {
|
||||
// Please note: checking where the feature is visible or not should not be in the scope of the
|
||||
// feature itself. Theoretically, the router's config should avoid managing the route in some
|
||||
// circumstances, and the sidebar button should be rendered or not based on the same condition.
|
||||
// But the feature itself should not be aware of when it's rendered or not.
|
||||
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
if (!isProLiteConsole(window.__env)) return null;
|
||||
|
||||
return <OpenTelemetryProvider />;
|
||||
}
|
@ -0,0 +1,130 @@
|
||||
import type { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import produce from 'immer';
|
||||
import { expect } from '@storybook/jest';
|
||||
import { act } from '@testing-library/react';
|
||||
import { userEvent, waitFor, within } from '@storybook/testing-library';
|
||||
import { ReduxDecorator } from '@/storybook/decorators/redux-decorator';
|
||||
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
|
||||
|
||||
import { createDefaultInitialData, handlers } from '@/mocks/metadata.mock';
|
||||
|
||||
import { OpenTelemetryProvider } from './OpenTelemetryProvider';
|
||||
|
||||
// --------------------------------------------------
|
||||
// NOT TESTED
|
||||
// --------------------------------------------------
|
||||
// The following scenarios do not have interaction tests because...:
|
||||
// - The success and error notification: because it's better to test it through a browser test until showing the notifications in Storybook is not possible
|
||||
|
||||
export default {
|
||||
title: 'Features/OpenTelemetry/OpenTelemetryProvider',
|
||||
component: OpenTelemetryProvider,
|
||||
decorators: [
|
||||
ReduxDecorator({ tables: { currentDataSource: 'default' } }),
|
||||
ReactQueryDecorator(),
|
||||
],
|
||||
parameters: {
|
||||
msw: handlers({
|
||||
// Allows to see the loading states
|
||||
delay: 500,
|
||||
|
||||
// This story requires just the OpenTelemetry-related metadata handlers
|
||||
initialData: produce(createDefaultInitialData(), draft => {
|
||||
draft.metadata.opentelemetry = undefined;
|
||||
}),
|
||||
}),
|
||||
},
|
||||
} as ComponentMeta<typeof OpenTelemetryProvider>;
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// DEFAULT STORY
|
||||
// #region
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
// --------------------------------------------------
|
||||
// STORY DEFINITION
|
||||
// --------------------------------------------------
|
||||
export const Default: ComponentStory<typeof OpenTelemetryProvider> = () => {
|
||||
return <OpenTelemetryProvider />;
|
||||
};
|
||||
|
||||
Default.storyName = '💠 Default';
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// HAPPY PATH TEST
|
||||
// #region
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
// --------------------------------------------------
|
||||
// STORY DEFINITION
|
||||
// --------------------------------------------------
|
||||
export const HappyPath: ComponentStory<typeof OpenTelemetryProvider> = () => (
|
||||
<OpenTelemetryProvider />
|
||||
);
|
||||
|
||||
HappyPath.storyName =
|
||||
'🧪 Testing - When enable OpenTelemetry, it should update the OpenTelemetry metadata';
|
||||
|
||||
HappyPath.parameters = {
|
||||
chromatic: { disableSnapshot: true },
|
||||
msw: handlers({
|
||||
// Speeds up the test as much as possible
|
||||
delay: 0,
|
||||
|
||||
// This story requires just the OpenTelemetry-related metadata handlers
|
||||
initialData: produce(createDefaultInitialData(), draft => {
|
||||
draft.metadata.opentelemetry = undefined;
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
// --------------------------------------------------
|
||||
// INTERACTION TEST
|
||||
// --------------------------------------------------
|
||||
HappyPath.play = async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
// STEP: Wait until the metadata has been loaded (through waiting for the submit button being enabled)
|
||||
const submitButton = await canvas.findByRole('button', { name: 'Connect' });
|
||||
await waitFor(() => {
|
||||
expect(submitButton).toBeEnabled();
|
||||
});
|
||||
|
||||
// STEP: Check the badge shows OpenTelemetry is disabled
|
||||
const badge = await canvas.findByTestId('badge');
|
||||
expect(badge).toHaveTextContent('Disabled');
|
||||
|
||||
// act avoids the "When testing, code that causes React state updates should be wrapped into act(...):" error
|
||||
await act(async () => {
|
||||
// STEP: Enable OpenTelemetry
|
||||
await userEvent.click(await canvas.findByLabelText('Status'));
|
||||
|
||||
// STEP: Type the Endpoint
|
||||
await userEvent.type(
|
||||
await canvas.findByLabelText('Endpoint', { selector: 'input' }),
|
||||
'http://hasura.io'
|
||||
);
|
||||
|
||||
// STEP: Click the Submit button
|
||||
await userEvent.click(submitButton);
|
||||
});
|
||||
|
||||
// STEP: Wait for OpenTelemetry to be enabled (through waiting for the badge to show "Enabled"
|
||||
// since the badge update only after updating the metadata and reloading it)
|
||||
await waitFor(async () => {
|
||||
expect(await canvas.findByTestId('badge')).toHaveTextContent('Enabled');
|
||||
});
|
||||
};
|
||||
|
||||
// #endregion
|
@ -0,0 +1,26 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { OpenTelemetry } from '../OpenTelemetry/OpenTelemetry';
|
||||
|
||||
import { useOpenTelemetry } from './hooks/useOpenTelemetry';
|
||||
import { useSetOpenTelemetry } from './hooks/useSetOpenTelemetry';
|
||||
|
||||
/**
|
||||
* Allow isolating OpenTelemetry (the UI core of the feature) from every ap logic like
|
||||
* notifications, metadata loading, etc.
|
||||
*/
|
||||
export function OpenTelemetryProvider() {
|
||||
const { isLoadingMetadata, metadataFormValues, isFirstTimeSetup } =
|
||||
useOpenTelemetry();
|
||||
|
||||
const { setOpenTelemetry } = useSetOpenTelemetry();
|
||||
|
||||
return (
|
||||
<OpenTelemetry
|
||||
isFirstTimeSetup={isFirstTimeSetup}
|
||||
skeletonMode={isLoadingMetadata}
|
||||
metadataFormValues={metadataFormValues}
|
||||
setOpenTelemetry={setOpenTelemetry}
|
||||
/>
|
||||
);
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
import { useFireNotification } from '@/new-components/Notifications';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* In case of metadata loading failure, the users cannot do anything but reloading the page.
|
||||
* ATTENTION: At the time of writing, there is not a common way to handle metadata failures.
|
||||
*/
|
||||
export function useNotifyMetadataLoadingError(loadingMetadataFailed: boolean) {
|
||||
const { fireNotification } = useFireNotification();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadingMetadataFailed) return;
|
||||
|
||||
fireNotification({
|
||||
title: 'Error!',
|
||||
message: 'Failed to load the metadata. Please reload the page.',
|
||||
type: 'error',
|
||||
});
|
||||
}, [
|
||||
// fireNotification is a stable reference
|
||||
fireNotification,
|
||||
loadingMetadataFailed,
|
||||
]);
|
||||
}
|
@ -0,0 +1,91 @@
|
||||
import {
|
||||
trackCustomEvent,
|
||||
programmaticallyTraceError,
|
||||
} from '@/features/Analytics';
|
||||
|
||||
import {
|
||||
parseUnexistingEnvVarSchemaError,
|
||||
parseHasuraEnvVarsNotAllowedError,
|
||||
} from '@/features/hasura-metadata-types';
|
||||
|
||||
import { useFireNotification } from '@/new-components/Notifications';
|
||||
|
||||
export function useOnSetOpenTelemetryError(
|
||||
fireNotification: ReturnType<typeof useFireNotification>['fireNotification']
|
||||
) {
|
||||
return function onSetOpenTelemetryError(err: unknown) {
|
||||
// UNEXISTING ENV VAR ERROR
|
||||
const parseUnexistingEnvVarSchemaResult =
|
||||
parseUnexistingEnvVarSchemaError(err);
|
||||
if (parseUnexistingEnvVarSchemaResult.success) {
|
||||
fireNotification({
|
||||
type: 'error',
|
||||
title: 'Error!',
|
||||
message: parseUnexistingEnvVarSchemaResult.data.internal[0].reason,
|
||||
});
|
||||
|
||||
trackCustomEvent(
|
||||
{
|
||||
location: 'OpenTelemetry',
|
||||
action: 'update OpenTelemetry',
|
||||
object: 'Unexisting env var error',
|
||||
},
|
||||
{
|
||||
severity: 'error',
|
||||
}
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// HASURA ENV VAR ERROR
|
||||
const parseHasuraEnvVarsNotAllowedResult =
|
||||
parseHasuraEnvVarsNotAllowedError(err);
|
||||
if (parseHasuraEnvVarsNotAllowedResult.success) {
|
||||
fireNotification({
|
||||
type: 'error',
|
||||
title: 'Error!',
|
||||
message: parseHasuraEnvVarsNotAllowedResult.data.error,
|
||||
});
|
||||
|
||||
trackCustomEvent(
|
||||
{
|
||||
location: 'OpenTelemetry',
|
||||
action: 'update OpenTelemetry',
|
||||
object: 'Hasura env var error',
|
||||
},
|
||||
{
|
||||
severity: 'error',
|
||||
}
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// UNEXPECTED ERROR
|
||||
fireNotification({
|
||||
type: 'error',
|
||||
title: 'Error!',
|
||||
message: JSON.stringify(err),
|
||||
});
|
||||
|
||||
trackCustomEvent(
|
||||
{
|
||||
location: 'OpenTelemetry',
|
||||
action: 'update OpenTelemetry',
|
||||
object: 'Unexpected error',
|
||||
},
|
||||
{
|
||||
severity: 'error',
|
||||
}
|
||||
);
|
||||
|
||||
programmaticallyTraceError(
|
||||
new Error(
|
||||
'OpenTelemetry set_opentelemetry_config error not parsed',
|
||||
// @ts-expect-error This error will automatically disappear with Nx that targets new browsers by default
|
||||
{ cause: err }
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
import { useMetadata } from '@/features/hasura-metadata-api';
|
||||
|
||||
import { openTelemetryToFormValues } from '../utils/openTelemetryToFormValues';
|
||||
import { useNotifyMetadataLoadingError } from './useNotifyMetadataLoadingError';
|
||||
import { useTrackTypeMisalignments } from './useTrackTypeMisalignments';
|
||||
|
||||
/**
|
||||
* Retrieve the OpenTelemetry configuration from the metadata.
|
||||
*/
|
||||
export function useOpenTelemetry() {
|
||||
const {
|
||||
data: openTelemetry,
|
||||
isLoading: isLoadingMetadata,
|
||||
isError: loadingMetadataFailed,
|
||||
} = useMetadata(metadata => metadata.metadata.opentelemetry);
|
||||
|
||||
useNotifyMetadataLoadingError(loadingMetadataFailed);
|
||||
useTrackTypeMisalignments(openTelemetry);
|
||||
|
||||
const metadataFormValues = openTelemetryToFormValues(openTelemetry);
|
||||
|
||||
return {
|
||||
isFirstTimeSetup: !openTelemetry,
|
||||
isLoadingMetadata,
|
||||
metadataFormValues,
|
||||
};
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
import type { SetOpenTelemetryQuery } from '@/features/hasura-metadata-types';
|
||||
|
||||
import {
|
||||
useMetadataVersion,
|
||||
useMetadataMigration,
|
||||
} from '@/features/MetadataAPI';
|
||||
|
||||
import { useFireNotification } from '@/new-components/Notifications';
|
||||
import { useInvalidateMetadata } from '@/features/hasura-metadata-api';
|
||||
|
||||
import type { FormValues } from '../../OpenTelemetry/components/Form/schema';
|
||||
|
||||
import { formValuesToOpenTelemetry } from '../utils/openTelemetryToFormValues';
|
||||
import { useOnSetOpenTelemetryError } from './useOnSetOpenTelemetryError';
|
||||
|
||||
type QueryArgs = SetOpenTelemetryQuery['args'];
|
||||
|
||||
function errorTransform(error: unknown) {
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow updating the OpenTelemetry configuration.
|
||||
*/
|
||||
export function useSetOpenTelemetry() {
|
||||
const mutation = useMetadataMigration({ errorTransform });
|
||||
const { data: version } = useMetadataVersion();
|
||||
const invalidateMetadata = useInvalidateMetadata();
|
||||
|
||||
const { fireNotification } = useFireNotification();
|
||||
|
||||
const onSetOpenTelemetryError = useOnSetOpenTelemetryError(fireNotification);
|
||||
|
||||
const setOpenTelemetry = (formValues: FormValues) => {
|
||||
const args: QueryArgs = formValuesToOpenTelemetry(formValues);
|
||||
|
||||
// Please note: not checking if the component is still mounted or not is made on purpose because
|
||||
// the callbacks do not direct mutate any component state.
|
||||
return new Promise<void>(resolve => {
|
||||
mutation.mutate(
|
||||
{
|
||||
query: {
|
||||
type: 'set_opentelemetry_config',
|
||||
args,
|
||||
resource_version: version,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
resolve();
|
||||
invalidateMetadata();
|
||||
|
||||
fireNotification({
|
||||
title: 'Success!',
|
||||
message: 'Successfully updated the OpenTelemetry Configuration',
|
||||
type: 'success',
|
||||
});
|
||||
},
|
||||
|
||||
onError: err => {
|
||||
// The promise is used by Rect hook form to stop show the loading spinner but React hook
|
||||
// form must not handle errors.
|
||||
resolve();
|
||||
|
||||
onSetOpenTelemetryError(err);
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
setOpenTelemetry,
|
||||
};
|
||||
}
|
@ -0,0 +1,68 @@
|
||||
import type { Metadata } from '@/features/hasura-metadata-types';
|
||||
|
||||
import { parseOpenTelemetry } from '@/features/hasura-metadata-types';
|
||||
import {
|
||||
programmaticallyTraceError,
|
||||
trackCustomEvent,
|
||||
} from '@/features/Analytics';
|
||||
|
||||
/**
|
||||
* Parse the OpenTelemetry config stored in metadata. There are two possibilities where server's
|
||||
* metadata is not aligned with the Console one:
|
||||
* 1. (NOT CAUGHT) When the server adds more data than the expected ones. In this case, the Console
|
||||
* for sure will lose the extra data because the immutability nature of the Console brings to never
|
||||
* work with the original object. Even if catching this error would be a nice to have, it's not easy
|
||||
* to implement since zod's passThrough is not easy to be used along the whole data-management chain.
|
||||
* Zod's strict(), instead, is not useable with discriminated unions.
|
||||
* 2. (CAUGHT) When the server removes/changes some data. In this case, the Console reports the error
|
||||
* in Sentry.
|
||||
*
|
||||
* Theoretically speaking, every misalignment between the server and the Console is a huge problem
|
||||
* (especially if the server object contains less properties than what the Console expects) and
|
||||
* breaking the Console through an error could be the best thing to do (instead of using
|
||||
* programmaticallyTraceError). Anyway, most of the misalignments could be related to the validation
|
||||
* of the single properties (ex. the endpoint being `null` instead an empty string). In case of these
|
||||
* soft misalignments, the Console could continue to work as expected, hence it does not makes sense
|
||||
* to break it. The Console folks will notice the error in Sentry.
|
||||
*
|
||||
* PLEASE NOTE: The problem this function tries to work around is a pure communication problem that
|
||||
* happens only if the server and the Console are not aligned. When the TypeScript types coming from
|
||||
* the OpenAPI specs will be there, we do not need this function anymore.
|
||||
*
|
||||
* PEASE NOTE: Validating the metadata should happen in a centralized manner, not in every single
|
||||
* feature... Because, at the moment, the OpenTelemetry part never gets validated until the users
|
||||
* navigates to the OpenTelemetry page.
|
||||
*/
|
||||
export function useTrackTypeMisalignments(
|
||||
openTelemetry: Metadata['metadata']['opentelemetry']
|
||||
) {
|
||||
// metadata.opentelemetry is not there if the users never set it up
|
||||
if (openTelemetry === undefined) return;
|
||||
|
||||
const result = parseOpenTelemetry(openTelemetry);
|
||||
|
||||
if (result.success) return;
|
||||
|
||||
trackCustomEvent(
|
||||
{
|
||||
location: 'OpenTelemetry',
|
||||
action: 'parse',
|
||||
object: 'OpenTelemetry parser',
|
||||
},
|
||||
{
|
||||
severity: 'error',
|
||||
message: result.error.message,
|
||||
data: {
|
||||
openTelemetry: JSON.stringify(openTelemetry),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
programmaticallyTraceError(
|
||||
new Error(
|
||||
'OpenTelemetry metadata not parsed',
|
||||
// @ts-expect-error This error will automatically disappear with Nx that targets new browsers by default
|
||||
{ cause: result.error }
|
||||
)
|
||||
);
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import type { OpenTelemetry } from '@/features/hasura-metadata-types';
|
||||
import type { FormValues } from '../../../OpenTelemetryConfig/components/Form/schema';
|
||||
import type { FormValues } from '../../../OpenTelemetry/components/Form/schema';
|
||||
|
||||
type MetadataAttributes = OpenTelemetry['exporter_otlp']['resource_attributes'];
|
||||
type FormAttributes = FormValues['attributes'];
|
@ -1,5 +1,5 @@
|
||||
import type { OpenTelemetry } from '@/features/hasura-metadata-types';
|
||||
import type { FormValues } from '../../../OpenTelemetryConfig/components/Form/schema';
|
||||
import type { FormValues } from '../../../OpenTelemetry/components/Form/schema';
|
||||
|
||||
type MetadataHeaders = OpenTelemetry['exporter_otlp']['headers'];
|
||||
type FormHeaders = FormValues['headers'];
|
@ -0,0 +1,78 @@
|
||||
import type { Metadata } from '@/features/hasura-metadata-types';
|
||||
import type { FormValues } from '../../OpenTelemetry/components/Form/schema';
|
||||
|
||||
import {
|
||||
formValuesToOpenTelemetry,
|
||||
openTelemetryToFormValues,
|
||||
} from './openTelemetryToFormValues';
|
||||
|
||||
describe('openTelemetryToFormValues', () => {
|
||||
const openTelemetry: Metadata['metadata']['opentelemetry'] = {
|
||||
status: 'disabled',
|
||||
|
||||
exporter_otlp: {
|
||||
resource_attributes: [],
|
||||
protocol: 'http/protobuf',
|
||||
headers: [{ name: 'baz', value: 'qux' }],
|
||||
otlp_traces_endpoint: 'https://hasura.io',
|
||||
},
|
||||
|
||||
data_types: ['traces'],
|
||||
batch_span_processor: {
|
||||
max_export_batch_size: 100,
|
||||
},
|
||||
};
|
||||
|
||||
const formValues: FormValues = {
|
||||
enabled: false,
|
||||
|
||||
batchSize: 100,
|
||||
attributes: [],
|
||||
endpoint: 'https://hasura.io',
|
||||
headers: [{ name: 'baz', value: 'qux', type: 'from_value' }],
|
||||
|
||||
// At the beginning, only one Data Type is available
|
||||
dataType: ['traces'],
|
||||
// At the beginning, only one Connection Type is available
|
||||
connectionType: 'http/protobuf',
|
||||
};
|
||||
|
||||
it('When passed with a OpenTelemetry, should return the same values for the form', () => {
|
||||
expect(openTelemetryToFormValues(openTelemetry)).toEqual(formValues);
|
||||
});
|
||||
|
||||
it('When passed with some form values, should return the same values for the OpenTelemetry', () => {
|
||||
expect(formValuesToOpenTelemetry(formValues)).toEqual(openTelemetry);
|
||||
});
|
||||
|
||||
it('When passed with a disabled configuration and an empty endpoint, should strip out the endpoint from the OpenTelemetry that must be sent to the server', () => {
|
||||
expect(
|
||||
formValuesToOpenTelemetry({
|
||||
enabled: false,
|
||||
|
||||
batchSize: 100,
|
||||
attributes: [],
|
||||
endpoint: '',
|
||||
headers: [{ name: 'baz', value: 'qux', type: 'from_value' }],
|
||||
|
||||
// At the beginning, only one Data Type is available
|
||||
dataType: ['traces'],
|
||||
// At the beginning, only one Connection Type is available
|
||||
connectionType: 'http/protobuf',
|
||||
})
|
||||
).toEqual({
|
||||
status: 'disabled',
|
||||
|
||||
exporter_otlp: {
|
||||
resource_attributes: [],
|
||||
protocol: 'http/protobuf',
|
||||
headers: [{ name: 'baz', value: 'qux' }],
|
||||
},
|
||||
|
||||
data_types: ['traces'],
|
||||
batch_span_processor: {
|
||||
max_export_batch_size: 100,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
@ -1,7 +1,7 @@
|
||||
import type { Metadata, OpenTelemetry } from '@/features/hasura-metadata-types';
|
||||
import type { FormValues } from '../../OpenTelemetryConfig/components/Form/schema';
|
||||
import type { FormValues } from '../../OpenTelemetry/components/Form/schema';
|
||||
|
||||
import { defaultValues } from '../../OpenTelemetryConfig/components/Form/schema';
|
||||
import { defaultValues } from '../../OpenTelemetry/components/Form/schema';
|
||||
|
||||
import {
|
||||
formHeadersToMetadataHeaders,
|
||||
@ -16,24 +16,24 @@ import {
|
||||
* Convert a metadata's OpenTelemetry configuration into its corresponding form values object.
|
||||
*
|
||||
* ATTENTION: It takes for granted the OpenTelemetry configuration received from the server respects
|
||||
* the type! Misalignments, if any, must be caught before calling openTelemetryConfigToFormValues!
|
||||
* the type! Misalignments, if any, must be caught before calling openTelemetryToFormValues!
|
||||
*/
|
||||
export function openTelemetryConfigToFormValues(
|
||||
openTelemetryConfig: Metadata['metadata']['opentelemetry']
|
||||
export function openTelemetryToFormValues(
|
||||
openTelemetry: Metadata['metadata']['opentelemetry']
|
||||
): FormValues {
|
||||
if (!openTelemetryConfig) return defaultValues;
|
||||
if (!openTelemetry) return defaultValues;
|
||||
|
||||
if (openTelemetryConfig.status === 'disabled') {
|
||||
if (openTelemetry.status === 'disabled') {
|
||||
return {
|
||||
enabled: false,
|
||||
endpoint: openTelemetryConfig.exporter_otlp.otlp_traces_endpoint ?? '',
|
||||
endpoint: openTelemetry.exporter_otlp.otlp_traces_endpoint ?? '',
|
||||
headers: metadataHeadersToFormHeaders(
|
||||
openTelemetryConfig.exporter_otlp.headers
|
||||
openTelemetry.exporter_otlp.headers
|
||||
),
|
||||
batchSize: openTelemetryConfig.batch_span_processor.max_export_batch_size,
|
||||
batchSize: openTelemetry.batch_span_processor.max_export_batch_size,
|
||||
|
||||
attributes: metadataAttributesToFormAttributes(
|
||||
openTelemetryConfig.exporter_otlp.resource_attributes
|
||||
openTelemetry.exporter_otlp.resource_attributes
|
||||
),
|
||||
|
||||
// At the beginning, only one Data Type is available
|
||||
@ -45,14 +45,12 @@ export function openTelemetryConfigToFormValues(
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
endpoint: openTelemetryConfig.exporter_otlp.otlp_traces_endpoint,
|
||||
headers: metadataHeadersToFormHeaders(
|
||||
openTelemetryConfig.exporter_otlp.headers
|
||||
),
|
||||
batchSize: openTelemetryConfig.batch_span_processor.max_export_batch_size,
|
||||
endpoint: openTelemetry.exporter_otlp.otlp_traces_endpoint,
|
||||
headers: metadataHeadersToFormHeaders(openTelemetry.exporter_otlp.headers),
|
||||
batchSize: openTelemetry.batch_span_processor.max_export_batch_size,
|
||||
|
||||
attributes: metadataAttributesToFormAttributes(
|
||||
openTelemetryConfig.exporter_otlp.resource_attributes
|
||||
openTelemetry.exporter_otlp.resource_attributes
|
||||
),
|
||||
|
||||
// At the beginning, only one Data Type is available
|
||||
@ -65,7 +63,7 @@ export function openTelemetryConfigToFormValues(
|
||||
/**
|
||||
* Convert the form values their corresponding metadata object.
|
||||
*/
|
||||
export function formValuesToOpenTelemetryConfig(
|
||||
export function formValuesToOpenTelemetry(
|
||||
formValues: FormValues
|
||||
): OpenTelemetry {
|
||||
const otlp_traces_endpoint = formValues.endpoint;
|
||||
@ -80,7 +78,7 @@ export function formValuesToOpenTelemetryConfig(
|
||||
);
|
||||
|
||||
if (!formValues.enabled) {
|
||||
return {
|
||||
const disabledOpenTelemetry: OpenTelemetry = {
|
||||
status: 'disabled',
|
||||
|
||||
// At the beginning, only one Data Type is available
|
||||
@ -90,13 +88,18 @@ export function formValuesToOpenTelemetryConfig(
|
||||
headers,
|
||||
protocol,
|
||||
resource_attributes,
|
||||
otlp_traces_endpoint,
|
||||
},
|
||||
|
||||
batch_span_processor: {
|
||||
max_export_batch_size,
|
||||
},
|
||||
};
|
||||
|
||||
if (otlp_traces_endpoint) {
|
||||
disabledOpenTelemetry.exporter_otlp.otlp_traces_endpoint =
|
||||
otlp_traces_endpoint;
|
||||
}
|
||||
return disabledOpenTelemetry;
|
||||
}
|
||||
|
||||
return {
|
@ -0,0 +1,2 @@
|
||||
export * from './metadata.mock';
|
||||
export * from './OpenTelemetryFeature';
|
@ -4,11 +4,10 @@ import type { MetadataReducer } from '@/mocks/actions';
|
||||
import type { allowedMetadataTypes } from '@/features/MetadataAPI';
|
||||
import type {
|
||||
Metadata,
|
||||
SetOpenTelemetryConfigQuery,
|
||||
SetOpenTelemetryQuery,
|
||||
} from '@/features/hasura-metadata-types';
|
||||
import { parseOpenTelemetry } from '@/features/hasura-metadata-types';
|
||||
|
||||
export const openTelemetryConfigInitialData: Partial<Metadata['metadata']> = {
|
||||
export const openTelemetryInitialData: Partial<Metadata['metadata']> = {
|
||||
opentelemetry: undefined,
|
||||
};
|
||||
|
||||
@ -17,17 +16,12 @@ export const metadataHandlers: Partial<
|
||||
> = {
|
||||
// ATTENTION: the server errors that the Console prevents are not handled here
|
||||
set_opentelemetry_config: (state, action) => {
|
||||
// TODO: strongly type it
|
||||
const newOpenTelemetryConfig =
|
||||
action.args as SetOpenTelemetryConfigQuery['args'];
|
||||
const newOpenTelemetry = action.args as SetOpenTelemetryQuery['args'];
|
||||
|
||||
const result = parseOpenTelemetry(newOpenTelemetryConfig);
|
||||
if (!result.success) {
|
||||
throw result.error;
|
||||
}
|
||||
// TODO: manage error if needed
|
||||
|
||||
return produce(state, draft => {
|
||||
draft.metadata.opentelemetry = newOpenTelemetryConfig;
|
||||
draft.metadata.opentelemetry = newOpenTelemetry;
|
||||
});
|
||||
},
|
||||
};
|
@ -1,43 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { FormValues } from './components/Form/schema';
|
||||
import { defaultValues } from './components/Form/schema';
|
||||
|
||||
import { Form } from './components/Form/Form';
|
||||
import { Header } from './components/Header/Header';
|
||||
|
||||
interface OpenTelemetryConfigProps {
|
||||
skeletonMode: boolean;
|
||||
metadataFormValues: FormValues | undefined;
|
||||
updateOpenTelemetry: (formValues: FormValues) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* All the OpenTelemetry page without any external dependency.
|
||||
*/
|
||||
export function OpenTelemetryConfig(props: OpenTelemetryConfigProps) {
|
||||
const { skeletonMode, metadataFormValues, updateOpenTelemetry } = props;
|
||||
|
||||
const formValues = metadataFormValues || defaultValues;
|
||||
|
||||
const headerMode = skeletonMode
|
||||
? 'skeleton'
|
||||
: !formValues.enabled
|
||||
? 'disabled'
|
||||
: 'enabled';
|
||||
|
||||
return (
|
||||
<div className="space-y-md max-w-screen-md p-md">
|
||||
<Header mode={headerMode} />
|
||||
|
||||
<div>
|
||||
{/* This div avoid space-y-md separating too much the toggle and the form */}
|
||||
<Form
|
||||
onSubmit={updateOpenTelemetry}
|
||||
defaultValues={formValues}
|
||||
skeletonMode={skeletonMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
import type { Metadata } from '@/features/hasura-metadata-types';
|
||||
import type { FormValues } from '../../OpenTelemetryConfig/components/Form/schema';
|
||||
|
||||
import {
|
||||
formValuesToOpenTelemetryConfig,
|
||||
openTelemetryConfigToFormValues,
|
||||
} from './openTelemetryConfigToFormValues';
|
||||
|
||||
describe('openTelemetryConfigToFormValues', () => {
|
||||
const openTelemetryConfig: Metadata['metadata']['opentelemetry'] = {
|
||||
status: 'disabled',
|
||||
|
||||
exporter_otlp: {
|
||||
resource_attributes: [],
|
||||
protocol: 'http/protobuf',
|
||||
headers: [{ name: 'baz', value: 'qux' }],
|
||||
otlp_traces_endpoint: 'https://hasura.io',
|
||||
},
|
||||
|
||||
data_types: ['traces'],
|
||||
batch_span_processor: {
|
||||
max_export_batch_size: 100,
|
||||
},
|
||||
};
|
||||
|
||||
const formValues: FormValues = {
|
||||
enabled: false,
|
||||
|
||||
batchSize: 100,
|
||||
attributes: [],
|
||||
endpoint: 'https://hasura.io',
|
||||
headers: [{ name: 'baz', value: 'qux', type: 'from_value' }],
|
||||
|
||||
// At the beginning, only one Data Type is available
|
||||
dataType: ['traces'],
|
||||
// At the beginning, only one Connection Type is available
|
||||
connectionType: 'http/protobuf',
|
||||
};
|
||||
|
||||
it('When passed with a OpenTelemetryConfig, should return the same values for the form', () => {
|
||||
expect(openTelemetryConfigToFormValues(openTelemetryConfig)).toEqual(
|
||||
formValues
|
||||
);
|
||||
});
|
||||
|
||||
it('When passed with some form values, should return the same values for the OpenTelemetryConfig', () => {
|
||||
expect(formValuesToOpenTelemetryConfig(formValues)).toEqual(
|
||||
openTelemetryConfig
|
||||
);
|
||||
});
|
||||
});
|
@ -1 +0,0 @@
|
||||
export * from './metadata.mock';
|
@ -0,0 +1,65 @@
|
||||
import {
|
||||
parseUnexistingEnvVarSchemaError,
|
||||
parseHasuraEnvVarsNotAllowedError,
|
||||
} from './parsers';
|
||||
|
||||
// Testing parseUnexistingEnvVarSchemaError is important until it's based on the custom regex
|
||||
describe('parseUnexistingEnvVarSchemaError', () => {
|
||||
it('When invoked with a "unexistingEnvVar" error, then should return it', () => {
|
||||
const theRealServerError = {
|
||||
code: 'unexpected',
|
||||
error: 'cannot continue due to new inconsistent metadata',
|
||||
internal: [
|
||||
{
|
||||
definition: {
|
||||
headers: [{ name: 'foo', value_from_env: 'baz' }],
|
||||
otlp_traces_endpoint: 'http://example.io',
|
||||
protocol: 'http/protobuf',
|
||||
resource_attributes: [],
|
||||
},
|
||||
name: 'open_telemetry exporter_otlp',
|
||||
reason: "Inconsistent object: environment variable 'baz' not set",
|
||||
type: 'open_telemetry',
|
||||
},
|
||||
],
|
||||
path: '$.args',
|
||||
};
|
||||
|
||||
const theErrorTheConsoleMatters = {
|
||||
internal: [
|
||||
{
|
||||
reason: "Inconsistent object: environment variable 'baz' not set",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = parseUnexistingEnvVarSchemaError(theRealServerError);
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: theErrorTheConsoleMatters,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Testing parseHasuraEnvVarsNotAllowedError is important until it's based on the custom regex
|
||||
describe('parseHasuraEnvVarsNotAllowedError', () => {
|
||||
it('When invoked with a "hasuraEnvVarsNotAllowed" error, then should return it', () => {
|
||||
const theRealServerError = {
|
||||
code: 'parse-failed',
|
||||
error:
|
||||
'env variables starting with "HASURA_GRAPHQL_" are not allowed in value_from_env: HASURA_GRAPHQL_ENABLED_APIS',
|
||||
path: '$.args.exporter_otlp.headers[1]',
|
||||
};
|
||||
|
||||
const theErrorTheConsoleMatters = {
|
||||
error:
|
||||
'env variables starting with "HASURA_GRAPHQL_" are not allowed in value_from_env: HASURA_GRAPHQL_ENABLED_APIS',
|
||||
};
|
||||
|
||||
const result = parseHasuraEnvVarsNotAllowedError(theRealServerError);
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: theErrorTheConsoleMatters,
|
||||
});
|
||||
});
|
||||
});
|
@ -1,4 +1,8 @@
|
||||
import { openTelemetrySchema } from '.';
|
||||
import {
|
||||
openTelemetrySchema,
|
||||
unexistingEnvVarSchema,
|
||||
hasuraEnvVarsNotAllowedSchema,
|
||||
} from '.';
|
||||
|
||||
/**
|
||||
* Allow to parse the data the server returns and early catch possible console/server misalignments.
|
||||
@ -14,3 +18,10 @@ import { openTelemetrySchema } from '.';
|
||||
export function parseOpenTelemetry(object: unknown) {
|
||||
return openTelemetrySchema.safeParse(object);
|
||||
}
|
||||
|
||||
export function parseUnexistingEnvVarSchemaError(errorBody: unknown) {
|
||||
return unexistingEnvVarSchema.safeParse(errorBody);
|
||||
}
|
||||
export function parseHasuraEnvVarsNotAllowedError(errorBody: unknown) {
|
||||
return hasuraEnvVarsNotAllowedSchema.safeParse(errorBody);
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import type { OpenTelemetry } from './types';
|
||||
|
||||
export type OpenTelemetryQueries = SetOpenTelemetryConfigQuery['type'];
|
||||
export type OpenTelemetryQueries = SetOpenTelemetryQuery['type'];
|
||||
|
||||
// --------------------------------------------------
|
||||
// SET OPENTELEMETRY CONFIG
|
||||
@ -9,14 +10,14 @@ export type OpenTelemetryQueries = SetOpenTelemetryConfigQuery['type'];
|
||||
/**
|
||||
* Allow to set/update the OpenTelemetry configuration.
|
||||
*/
|
||||
export type SetOpenTelemetryConfigQuery = {
|
||||
export type SetOpenTelemetryQuery = {
|
||||
type: 'set_opentelemetry_config';
|
||||
|
||||
args: OpenTelemetry;
|
||||
|
||||
successResponse: { message: 'success' };
|
||||
|
||||
errorResponse: SetOpenTelemetryConfigQueryErrors;
|
||||
errorResponse: SetOpenTelemetryQueryErrors;
|
||||
};
|
||||
|
||||
// ERRORS
|
||||
@ -26,15 +27,75 @@ export type SetOpenTelemetryConfigQuery = {
|
||||
// - passing a wrong OpenTelemetry config, prevented by the Console
|
||||
// that would result in something like {"code":"unexpected","error":"cannot continue due to new inconsistent metadata","internal":[{"definition":{"headers":[],"protocol":"http/protobuf","resource_attributes":[]},"name":"open_telemetry exporter_otlp","reason":"Inconsistent object: Missing traces endpoint","type":"open_telemetry"}],"path":"$.args"}%
|
||||
// - all the internal server errors
|
||||
// ATTENTION: the `unknown` type is useful to force the consumer to manage every kind of possible
|
||||
// error object. Unfortunately, we do not have good visibility over the server errors yet, and
|
||||
// treating them as a black box (apart from some particular cases) is the only thing we can do.
|
||||
type SetOpenTelemetryConfigQueryErrors = ErrorsNotManagedByTheConsole | unknown;
|
||||
export type SetOpenTelemetryQueryErrors = NotPreventedByTheConsoleErrors;
|
||||
|
||||
type ErrorsNotManagedByTheConsole = {
|
||||
type NotPreventedByTheConsoleErrors = {
|
||||
httpStatus: 400;
|
||||
} & {
|
||||
code: 'parse-failed';
|
||||
error: `Environment variable not found: "${string}"`;
|
||||
path: '$.args';
|
||||
body:
|
||||
| z.infer<typeof hasuraEnvVarsNotAllowedSchema>
|
||||
| z.infer<typeof unexistingEnvVarSchema>
|
||||
| UnknownError;
|
||||
};
|
||||
|
||||
// Even in case of multiple HASURA_GRAPHQL_ env vars used, the server always report just one of them
|
||||
// in the error.
|
||||
export const hasuraEnvVarsNotAllowedSchema = z.object({
|
||||
// ATTENTION: the real server error contains more data, but it's useless to type it because it only
|
||||
// makes this schema more fragile. At the time of writing, the errors are not set in stone and can
|
||||
// change in the future.
|
||||
|
||||
error:
|
||||
// the use of z.custom is not more needed when this issue will be fixed: https://github.com/colinhacks/zod/issues/419
|
||||
z.custom<`env variables starting with "HASURA_GRAPHQL_" are not allowed in value_from_env: HASURA_GRAPHQL_${EnvVarName}`>(
|
||||
val => {
|
||||
if (typeof val !== 'string') return false;
|
||||
|
||||
// see: https://regex101.com/r/afZdol/1
|
||||
return /^env variables starting with \"HASURA_GRAPHQL_\" are not allowed in value_from_env: HASURA_GRAPHQL_(?<envVarName>.[A-Z_]+)$/.test(
|
||||
val
|
||||
);
|
||||
}
|
||||
),
|
||||
|
||||
// It would be nice to highlight the problematic env var in the Console's form but, at the time of
|
||||
// writing, the RequestHeadersSelector component is not connected to the form and cannot show
|
||||
// the input field errors. Hence the `path` returned by the server is currently useless.
|
||||
// path: `$.args.exporter_otlp.headers[${Base0Index}]`, // Base0Index = number
|
||||
});
|
||||
|
||||
// Even in case of multiple unexisting env vars, the server always report just one of them in the
|
||||
// reason.
|
||||
export const unexistingEnvVarSchema = z.object({
|
||||
// ATTENTION: the real server error contains more data, but it's useless to type it because it only
|
||||
// makes this schema more fragile. At the time of writing, the errors are not set in stone and can
|
||||
// change in the future.
|
||||
|
||||
internal: z.array(
|
||||
z.object({
|
||||
reason:
|
||||
z.custom<`Inconsistent object: environment variable '${EnvVarName}' not set`>(
|
||||
val => {
|
||||
if (typeof val !== 'string') return false;
|
||||
|
||||
// see: https://regex101.com/r/CBCkEd/1
|
||||
return /Inconsistent object: environment variable '(?<envVarName>.*?)' not set/.test(
|
||||
val
|
||||
);
|
||||
}
|
||||
),
|
||||
})
|
||||
),
|
||||
|
||||
// It would be nice to highlight the problematic env var in the Console's form but, at the time of
|
||||
// writing, the RequestHeadersSelector component is not connected to the form and cannot show
|
||||
// the input field errors. More, the server does not get the name of the problematic var so only
|
||||
// parsing the error message is possible (but it's better off to avoid it).
|
||||
});
|
||||
|
||||
type UnknownError = unknown;
|
||||
|
||||
type EnvVarName = string;
|
||||
|
||||
/^env variables starting with \\"HASURA_GRAPHQL_\\" are not allowed in value_from_env: HASURA_GRAPHQL_(?<envVarName>.[A-Z_]+)$/.test(
|
||||
'env variables starting with "HASURA_GRAPHQL_" are not allowed in value_from_env: HASURA_GRAPHQL_ENABLED_APIS'
|
||||
);
|
||||
|
@ -102,9 +102,11 @@ const disabledOpenTelemetrySchema = {
|
||||
|
||||
status: z.literal('disabled'),
|
||||
|
||||
exporter_otlp: exporterSchema.partial({
|
||||
exporter_otlp: exporterSchema.extend({
|
||||
// If OpenTelemetry is disabled, the endpoint is not required
|
||||
otlp_traces_endpoint: true,
|
||||
otlp_traces_endpoint: validUrlSchema
|
||||
.or(z.literal(''))
|
||||
.or(z.literal(undefined)),
|
||||
}),
|
||||
};
|
||||
|
||||
|
@ -172,5 +172,22 @@ describe('API functions', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('errorTransform', () => {
|
||||
it('when errorTransform is passed, should call the errorTransform function with the raw error', async () => {
|
||||
server.use(
|
||||
rest.get(url, (_req, res, context) => {
|
||||
// Make every request failing with { foo: 'bar' }
|
||||
return res(context.json({ foo: 'bar' }), context.status(400));
|
||||
})
|
||||
);
|
||||
|
||||
const errorTransform = jest.fn(error => error);
|
||||
const promise = Api.get({ url, headers }, undefined, errorTransform);
|
||||
|
||||
await expect(promise).rejects.toEqual({ foo: 'bar' });
|
||||
expect(errorTransform).toBeCalledWith({ foo: 'bar' });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -14,7 +14,16 @@ interface IApiArgs {
|
||||
|
||||
async function fetchApi<T = unknown, V = T>(
|
||||
args: IApiArgs,
|
||||
dataTransform?: (data: T) => V
|
||||
dataTransform?: (data: T) => V,
|
||||
/**
|
||||
* OpenTelemetry offers some new error objects because it uses a new server logic to validate
|
||||
* the users input (pease look at the OpenTelemetry errors to gather more info about the errors).
|
||||
* At the time of writing, the real reason of the failure would be hidden by the `APIError.fromUnknown`
|
||||
* function. At the same time, the OpenTelemetry errors are not a standard yet on the server, so
|
||||
* they need a custom management. As/if the new format will be used more, it would be better to
|
||||
* remove this custom function to avoid the proliferation of custom usages.
|
||||
*/
|
||||
errorTransform?: (error: unknown) => unknown
|
||||
): Promise<V> {
|
||||
try {
|
||||
const { headers, url, method, body, credentials } = args;
|
||||
@ -50,6 +59,10 @@ async function fetchApi<T = unknown, V = T>(
|
||||
const unknownError = await response.text();
|
||||
throw unknownError;
|
||||
} catch (error) {
|
||||
if (errorTransform) {
|
||||
throw errorTransform(error);
|
||||
}
|
||||
|
||||
throw APIError.fromUnknown(error);
|
||||
}
|
||||
}
|
||||
@ -57,32 +70,53 @@ async function fetchApi<T = unknown, V = T>(
|
||||
export namespace Api {
|
||||
export function get<T = unknown, V = T>(
|
||||
args: Omit<IApiArgs, 'body' | 'method'>,
|
||||
dataTransform?: (data: T) => V
|
||||
dataTransform?: (data: T) => V,
|
||||
errorTransform?: (error: unknown) => unknown
|
||||
) {
|
||||
return fetchApi<T, V>({ ...args, method: 'GET' }, dataTransform);
|
||||
return fetchApi<T, V>(
|
||||
{ ...args, method: 'GET' },
|
||||
dataTransform,
|
||||
errorTransform
|
||||
);
|
||||
}
|
||||
export function post<T = unknown, V = T>(
|
||||
args: Omit<IApiArgs, 'method'>,
|
||||
dataTransform?: (data: T) => V
|
||||
dataTransform?: (data: T) => V,
|
||||
errorTransform?: (error: unknown) => unknown
|
||||
) {
|
||||
return fetchApi<T, V>({ ...args, method: 'POST' }, dataTransform);
|
||||
return fetchApi<T, V>(
|
||||
{ ...args, method: 'POST' },
|
||||
dataTransform,
|
||||
errorTransform
|
||||
);
|
||||
}
|
||||
export function put<T = unknown, V = T>(
|
||||
args: Omit<IApiArgs, 'method'>,
|
||||
dataTransform?: (data: T) => V
|
||||
dataTransform?: (data: T) => V,
|
||||
errorTransform?: (error: unknown) => unknown
|
||||
) {
|
||||
return fetchApi<T, V>({ ...args, method: 'PUT' }, dataTransform);
|
||||
return fetchApi<T, V>(
|
||||
{ ...args, method: 'PUT' },
|
||||
dataTransform,
|
||||
errorTransform
|
||||
);
|
||||
}
|
||||
export function del<T = unknown, V = T>(
|
||||
args: Omit<IApiArgs, 'method'>,
|
||||
dataTransform?: (data: T) => V
|
||||
dataTransform?: (data: T) => V,
|
||||
errorTransform?: (error: unknown) => unknown
|
||||
) {
|
||||
return fetchApi<T, V>({ ...args, method: 'DELETE' }, dataTransform);
|
||||
return fetchApi<T, V>(
|
||||
{ ...args, method: 'DELETE' },
|
||||
dataTransform,
|
||||
errorTransform
|
||||
);
|
||||
}
|
||||
export function base<T = unknown, V = T>(
|
||||
args: IApiArgs,
|
||||
dataTransform?: (data: T) => V
|
||||
dataTransform?: (data: T) => V,
|
||||
errorTransform?: (error: unknown) => unknown
|
||||
) {
|
||||
return fetchApi<T, V>(args, dataTransform);
|
||||
return fetchApi<T, V>(args, dataTransform, errorTransform);
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import { allowedMetadataTypes } from '@/features/MetadataAPI';
|
||||
import { metadataHandlers as allowListMetadataHandlers } from '@/features/AllowLists';
|
||||
import { metadataHandlers as adhocEventMetadataHandlers } from '@/features/AdhocEvents';
|
||||
import { metadataHandlers as queryCollectionMetadataHandlers } from '@/features/QueryCollections';
|
||||
import { metadataHandlers as openTelemetryMetadataHandlers } from '@/features/OpenTelemetryConfig';
|
||||
import { metadataHandlers as openTelemetryMetadataHandlers } from '@/features/OpenTelemetry';
|
||||
|
||||
import { TMigration } from '../features/MetadataAPI/hooks/useMetadataMigration';
|
||||
|
||||
|
@ -4,7 +4,7 @@ import type { Metadata } from '@/features/hasura-metadata-types';
|
||||
|
||||
import { allowListInitialData } from '@/features/AllowLists';
|
||||
import { queryCollectionInitialData } from '@/features/QueryCollections';
|
||||
import { openTelemetryConfigInitialData } from '@/features/OpenTelemetryConfig';
|
||||
import { openTelemetryInitialData } from '@/features/OpenTelemetry';
|
||||
|
||||
import { rest } from 'msw';
|
||||
import { metadataReducer } from './actions';
|
||||
@ -17,7 +17,7 @@ export const createDefaultInitialData = (): Metadata => ({
|
||||
inherited_roles: [],
|
||||
...allowListInitialData,
|
||||
...queryCollectionInitialData,
|
||||
...openTelemetryConfigInitialData,
|
||||
...openTelemetryInitialData,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -33,6 +33,7 @@ export const Badge: React.FC<React.PropsWithChildren<BadgeProps>> = ({
|
||||
}) => {
|
||||
return (
|
||||
<span
|
||||
data-testid="badge"
|
||||
{...rest}
|
||||
className={clsx(
|
||||
'inline-flex items-center px-sm py-0.5 rounded-full text-sm tracking-wide font-semibold',
|
||||
|
@ -105,6 +105,26 @@ VariantWithDescriptionAndTooltip.parameters = {
|
||||
},
|
||||
};
|
||||
|
||||
export const VariantWithDescriptionAndTooltipAndKnwMoreLink: ComponentStory<
|
||||
typeof FieldWrapper
|
||||
> = () => (
|
||||
<FieldWrapper
|
||||
label="The field wrapper label"
|
||||
description="The field wrapper description"
|
||||
tooltip="The field wrapper tooltip"
|
||||
knowMoreLink="https://hasura.io/docs"
|
||||
>
|
||||
<ChildrenExample />
|
||||
</FieldWrapper>
|
||||
);
|
||||
VariantWithDescriptionAndTooltipAndKnwMoreLink.storyName =
|
||||
'🎭 Variant - With description, tooltip, and know more link';
|
||||
VariantWithDescriptionAndTooltipAndKnwMoreLink.parameters = {
|
||||
docs: {
|
||||
source: { state: 'open' },
|
||||
},
|
||||
};
|
||||
|
||||
export const StateLoading: ComponentStory<typeof FieldWrapper> = () => (
|
||||
<FieldWrapper
|
||||
label="The field wrapper label"
|
||||
|
@ -5,6 +5,7 @@ import Skeleton from 'react-loading-skeleton';
|
||||
import { FaExclamationCircle } from 'react-icons/fa';
|
||||
|
||||
import { IconTooltip } from '@/new-components/Tooltip';
|
||||
import { KnowMoreLink } from '../KnowMoreLink';
|
||||
|
||||
type FieldWrapperProps = {
|
||||
/**
|
||||
@ -40,10 +41,6 @@ type FieldWrapperProps = {
|
||||
* The field description
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* The field tooltip label
|
||||
*/
|
||||
tooltip?: React.ReactNode;
|
||||
/**
|
||||
* The field data test id for testing
|
||||
*/
|
||||
@ -60,6 +57,14 @@ type FieldWrapperProps = {
|
||||
* Render line breaks in the description
|
||||
*/
|
||||
renderDescriptionLineBreaks?: boolean;
|
||||
/**
|
||||
* The field tooltip label
|
||||
*/
|
||||
tooltip?: React.ReactNode;
|
||||
/**
|
||||
* The link containing more information about the field
|
||||
*/
|
||||
knowMoreLink?: string;
|
||||
};
|
||||
|
||||
export type FieldWrapperPassThroughProps = Omit<
|
||||
@ -97,6 +102,7 @@ export const FieldWrapper = (props: FieldWrapperProps) => {
|
||||
id,
|
||||
labelIcon,
|
||||
label,
|
||||
knowMoreLink,
|
||||
className,
|
||||
size = 'full',
|
||||
error,
|
||||
@ -141,14 +147,15 @@ export const FieldWrapper = (props: FieldWrapperProps) => {
|
||||
|
||||
if (label) {
|
||||
FieldLabel = () => (
|
||||
<label htmlFor={id} className={clsx('block pt-1 text-muted mb-xs')}>
|
||||
<span className={clsx('flex items-center font-semibold')}>
|
||||
<span className={loading ? 'relative' : ''}>
|
||||
<label htmlFor={id} className={clsx('block pt-1 text-gray-600 mb-xs')}>
|
||||
<span className={clsx('flex items-center')}>
|
||||
<span className={clsx('font-semibold', { relative: !!loading })}>
|
||||
<FieldLabelIcon />
|
||||
{label}
|
||||
{loading ? <Skeleton className="absolute inset-0" /> : null}
|
||||
</span>
|
||||
{!loading && tooltip ? <IconTooltip message={tooltip} /> : null}
|
||||
{!loading && !!knowMoreLink && <KnowMoreLink href={knowMoreLink} />}
|
||||
</span>
|
||||
<FieldDescription />
|
||||
</label>
|
||||
|
@ -0,0 +1,44 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import type { ComponentMeta, ComponentStory } from '@storybook/react';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { KnowMoreLink } from './KnowMoreLink';
|
||||
|
||||
export default {
|
||||
title: 'components/KnowMoreLink ⚛️',
|
||||
component: KnowMoreLink,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `Useful to link the docs of external resource containing more information`,
|
||||
},
|
||||
source: { type: 'code' },
|
||||
},
|
||||
},
|
||||
} as ComponentMeta<typeof KnowMoreLink>;
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// DEFAULT STORY
|
||||
// #region
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
// --------------------------------------------------
|
||||
// STORY DEFINITION
|
||||
// --------------------------------------------------
|
||||
|
||||
export const Basic: ComponentStory<typeof KnowMoreLink> = args => (
|
||||
<KnowMoreLink {...args} />
|
||||
);
|
||||
Basic.storyName = '🧰 Basic';
|
||||
|
||||
const basicArgs: ComponentPropsWithoutRef<typeof KnowMoreLink> = {
|
||||
href: 'https://hasura.io/docs',
|
||||
};
|
||||
Basic.args = basicArgs;
|
||||
|
||||
// #endregion
|
@ -0,0 +1,24 @@
|
||||
import * as React from 'react';
|
||||
|
||||
interface KnowMoreLinkProps {
|
||||
href: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The updated KnowMoreLink component.
|
||||
* @see https://github.com/hasura/graphql-engine-mono/pull/7023
|
||||
*/
|
||||
export const KnowMoreLink: React.VFC<KnowMoreLinkProps> = props => {
|
||||
const { href } = props;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-xs italic text-sm text-secondary"
|
||||
>
|
||||
(Know More)
|
||||
</a>
|
||||
);
|
||||
};
|
@ -0,0 +1 @@
|
||||
export { KnowMoreLink } from './KnowMoreLink';
|
@ -37,7 +37,6 @@ import {
|
||||
INSECURE_TLS_ALLOW_LIST,
|
||||
} from './helpers/versionUtils';
|
||||
import AuthContainer from './components/Services/Auth/AuthContainer';
|
||||
import { PrometheusSettings } from './features/Prometheus';
|
||||
import { FeatureFlags } from './features/FeatureFlags';
|
||||
import { AllowListDetail } from './components/Services/AllowList';
|
||||
|
||||
@ -158,7 +157,6 @@ const routes = store => {
|
||||
{checkFeatureSupport(INSECURE_TLS_ALLOW_LIST) && (
|
||||
<Route path="insecure-domain" component={InsecureDomains} />
|
||||
)}
|
||||
<Route path="prometheus-settings" component={PrometheusSettings} />
|
||||
<Route path="feature-flags" component={FeatureFlags} />
|
||||
</Route>
|
||||
{dataRouter}
|
||||
|
@ -44,6 +44,7 @@ import {
|
||||
isMonitoringTabSupportedEnvironment,
|
||||
AllowListDetail,
|
||||
PrometheusSettings,
|
||||
OpenTelemetryFeature,
|
||||
} from '@hasura/console-oss';
|
||||
import AccessDeniedComponent from './components/AccessDenied/AccessDenied';
|
||||
import { restrictedPathsMetadata } from './utils/redirectUtils';
|
||||
@ -355,6 +356,7 @@ const routes = store => {
|
||||
<Route path="inherited-roles" component={InheritedRolesContainer} />
|
||||
<Route path="insecure-domain" component={InsecureDomains} />
|
||||
<Route path="prometheus-settings" component={PrometheusSettings} />
|
||||
<Route path="opentelemetry" component={OpenTelemetryFeature} />
|
||||
<Route path="feature-flags" component={FeatureFlags} />
|
||||
</Route>
|
||||
{dataRouter}
|
||||
|
Loading…
Reference in New Issue
Block a user