diff --git a/src/Components/IndexScene/IndexScene.tsx b/src/Components/IndexScene/IndexScene.tsx index f2b3e14e..5cc19628 100644 --- a/src/Components/IndexScene/IndexScene.tsx +++ b/src/Components/IndexScene/IndexScene.tsx @@ -13,7 +13,6 @@ import { SceneObjectUrlSyncConfig, SceneObjectUrlValues, SceneRefreshPicker, - SceneRouteMatch, SceneTimePicker, SceneTimeRange, SceneVariableSet, @@ -54,6 +53,7 @@ import { getUrlParamNameForVariable, } from '../../services/variableGetters'; import { ToolbarScene } from './ToolbarScene'; +import { OptionalRouteMatch } from '../Pages'; export interface AppliedPattern { pattern: string; @@ -67,7 +67,7 @@ export interface IndexSceneState extends SceneObjectState { body?: LayoutScene; initialFilters?: AdHocVariableFilter[]; patterns?: AppliedPattern[]; - routeMatch?: SceneRouteMatch<{ service?: string; label?: string }>; + routeMatch?: OptionalRouteMatch; } export class IndexScene extends SceneObjectBase { @@ -122,7 +122,7 @@ export class IndexScene extends SceneObjectBase { const stateUpdate: Partial = {}; if (!this.state.contentScene) { - stateUpdate.contentScene = getContentScene(this.state.routeMatch?.params.label); + stateUpdate.contentScene = getContentScene(this.state.routeMatch?.params.breakdownLabel); } this.setState(stateUpdate); diff --git a/src/Components/Pages.tsx b/src/Components/Pages.tsx index 50090a0f..a428902d 100644 --- a/src/Components/Pages.tsx +++ b/src/Components/Pages.tsx @@ -9,10 +9,8 @@ import { import { CHILD_ROUTE_DEFINITIONS, ChildDrilldownSlugs, - ValueSlugs, DRILLDOWN_URL_KEYS, - extractLabelNameFromRoute, - extractServiceFromRoute, + extractValuesFromRoute, PageSlugs, ParentDrilldownSlugs, PLUGIN_BASE_URL, @@ -21,12 +19,20 @@ import { ROUTES, SERVICE_URL_KEYS, SUB_ROUTES, + ValueSlugs, } from '../services/routing'; import { PageLayoutType } from '@grafana/data'; import { IndexScene } from './IndexScene/IndexScene'; import { navigateToIndex } from '../services/navigate'; +import { logger } from '../services/logger'; -function getServicesScene(routeMatch?: SceneRouteMatch<{ service?: string; label?: string }>) { +export type RouteProps = { labelName: string; labelValue: string; breakdownLabel?: string }; +export type RouteMatch = SceneRouteMatch; +type Optional = Pick, K> & Omit; +export type OptionalRouteProps = Optional; +export type OptionalRouteMatch = SceneRouteMatch; + +function getServicesScene(routeMatch: OptionalRouteMatch) { const DEFAULT_TIME_RANGE = { from: 'now-15m', to: 'now' }; return new EmbeddedScene({ body: new IndexScene({ @@ -70,7 +76,7 @@ export function makeIndexPage() { }, { routePath: CHILD_ROUTE_DEFINITIONS.field, - getPage: (routeMatch, parent) => makeBreakdownValuePage(routeMatch, parent, ValueSlugs.field), + getPage: (routeMatch: RouteMatch, parent) => makeBreakdownValuePage(routeMatch, parent, ValueSlugs.field), }, { routePath: '*', @@ -107,15 +113,15 @@ function makeEmptyScene(): (routeMatch: SceneRouteMatch) => EmbeddedScene { } export function makeBreakdownPage( - routeMatch: SceneRouteMatch<{ service: string; label?: string }>, + routeMatch: RouteMatch, parent: SceneAppPageLike, slug: ParentDrilldownSlugs ): SceneAppPage { - const { service } = extractServiceFromRoute(routeMatch); + const { labelName, labelValue } = extractValuesFromRoute(routeMatch); return new SceneAppPage({ title: slugToBreadcrumbTitle(slug), layout: PageLayoutType.Custom, - url: ROUTES[slug](service), + url: ROUTES[slug](labelValue, labelName), preserveUrlKeys: DRILLDOWN_URL_KEYS, getParentPage: () => parent, getScene: (routeMatch) => getServicesScene(routeMatch), @@ -123,17 +129,22 @@ export function makeBreakdownPage( } export function makeBreakdownValuePage( - routeMatch: SceneRouteMatch<{ service: string; label: string }>, + routeMatch: RouteMatch, parent: SceneAppPageLike, slug: ChildDrilldownSlugs ): SceneAppPage { - const { service } = extractServiceFromRoute(routeMatch); - const { label } = extractLabelNameFromRoute(routeMatch); + const { labelName, labelValue, breakdownLabel } = extractValuesFromRoute(routeMatch); + + if (!breakdownLabel) { + const e = new Error('Breakdown value missing!'); + logger.error(e, { labelName, labelValue, breakdownLabel: breakdownLabel ?? '' }); + throw e; + } return new SceneAppPage({ - title: slugToBreadcrumbTitle(label), + title: slugToBreadcrumbTitle(breakdownLabel), layout: PageLayoutType.Custom, - url: SUB_ROUTES[slug](service, label), + url: SUB_ROUTES[slug](labelValue, labelName, breakdownLabel), preserveUrlKeys: DRILLDOWN_URL_KEYS, getParentPage: () => parent, getScene: (routeMatch) => getServicesScene(routeMatch), diff --git a/src/Components/ServiceScene/ActionBarScene.tsx b/src/Components/ServiceScene/ActionBarScene.tsx index 94b59ea7..10c95696 100644 --- a/src/Components/ServiceScene/ActionBarScene.tsx +++ b/src/Components/ServiceScene/ActionBarScene.tsx @@ -4,14 +4,12 @@ import { getExplorationFor } from '../../services/scenes'; import { getDrilldownSlug, getDrilldownValueSlug, PageSlugs, ValueSlugs } from '../../services/routing'; import { GoToExploreButton } from './GoToExploreButton'; import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from '../../services/analytics'; -import { SERVICE_NAME } from '../../services/variables'; -import { navigateToDrilldownPage, navigateToIndex } from '../../services/navigate'; +import { navigateToDrilldownPage } from '../../services/navigate'; import React from 'react'; import { ServiceScene, ServiceSceneState } from './ServiceScene'; import { GrafanaTheme2 } from '@grafana/data'; import { css } from '@emotion/css'; import { BreakdownViewDefinition, breakdownViewsDefinitions } from './BreakdownViews'; -import { getLabelsVariable } from '../../services/variableGetters'; export interface ActionBarSceneState extends SceneObjectState {} @@ -67,14 +65,7 @@ export class ActionBarScene extends SceneObjectBase { ); const serviceScene = sceneGraph.getAncestor(model, ServiceScene); - const variable = getLabelsVariable(serviceScene); - const service = variable.state.filters.find((f) => f.key === SERVICE_NAME); - - if (service?.value) { - navigateToDrilldownPage(tab.value, serviceScene); - } else { - navigateToIndex(); - } + navigateToDrilldownPage(tab.value, serviceScene); } }} /> diff --git a/src/Components/ServiceScene/Breakdowns/FieldsAggregatedBreakdownScene.tsx b/src/Components/ServiceScene/Breakdowns/FieldsAggregatedBreakdownScene.tsx index 0e583c30..5f5e530a 100644 --- a/src/Components/ServiceScene/Breakdowns/FieldsAggregatedBreakdownScene.tsx +++ b/src/Components/ServiceScene/Breakdowns/FieldsAggregatedBreakdownScene.tsx @@ -249,10 +249,8 @@ export class FieldsAggregatedBreakdownScene extends SceneObjectBase !child.state.isHidden); - if (activePanels) { - const fieldsBreakdownScene = sceneGraph.getAncestor(this, FieldsBreakdownScene); - fieldsBreakdownScene.state.changeFieldCount?.(activePanels.length); - } + const fieldsBreakdownScene = sceneGraph.getAncestor(this, FieldsBreakdownScene); + fieldsBreakdownScene.state.changeFieldCount?.(activePanels?.length ?? 0); } public static Selector({ model }: SceneComponentProps) { diff --git a/src/Components/ServiceScene/Breakdowns/FieldsBreakdownScene.tsx b/src/Components/ServiceScene/Breakdowns/FieldsBreakdownScene.tsx index 3c7e84b6..747d7a88 100644 --- a/src/Components/ServiceScene/Breakdowns/FieldsBreakdownScene.tsx +++ b/src/Components/ServiceScene/Breakdowns/FieldsBreakdownScene.tsx @@ -19,11 +19,11 @@ import { import { Alert, Button, useStyles2 } from '@grafana/ui'; import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from 'services/analytics'; import { getSortByPreference } from 'services/store'; -import { ALL_VARIABLE_VALUE, SERVICE_NAME, VAR_FIELD_GROUP_BY, VAR_LABELS } from 'services/variables'; +import { ALL_VARIABLE_VALUE, SERVICE_NAME, VAR_FIELD_GROUP_BY, VAR_LABELS, VAR_SERVICE } from 'services/variables'; import { areArraysEqual } from '../../../services/comparison'; import { CustomConstantVariable, CustomConstantVariableState } from '../../../services/CustomConstantVariable'; import { navigateToValueBreakdown } from '../../../services/navigate'; -import { ValueSlugs } from '../../../services/routing'; +import { checkPrimaryLabel, getPrimaryLabelFromUrl, ValueSlugs } from '../../../services/routing'; import { DEFAULT_SORT_BY } from '../../../services/sorting'; import { GrotError } from '../../GrotError'; import { IndexScene } from '../../IndexScene/IndexScene'; @@ -108,10 +108,12 @@ export class FieldsBreakdownScene extends SceneObjectBase { const variable = getFieldGroupByVariable(this); - const newService = newState.filters.find((filter) => filter.key === SERVICE_NAME); - const prevService = prevState.filters.find((filter) => filter.key === SERVICE_NAME); + let { labelName } = getPrimaryLabelFromUrl(); - // If the user changes the service + const newService = newState.filters.find((filter) => filter.key === labelName); + const prevService = prevState.filters.find((filter) => filter.key === labelName); + + // If the user changes the primary label if (variable.state.value === ALL_VARIABLE_VALUE && newService !== prevService) { this.setState({ loading: true, @@ -138,6 +140,8 @@ export class FieldsBreakdownScene extends SceneObjectBase { @@ -159,6 +163,7 @@ export class FieldsBreakdownScene extends SceneObjectBase 1) { + this.state.changeFieldCount?.(0); body = this.buildClearFiltersLayout(() => this.clearVariables(variablesToClear)); } else { body = new EmptyLayoutScene({ type: 'fields' }); @@ -220,6 +225,7 @@ export class FieldsBreakdownScene extends SceneObjectBase 1) { + this.state.changeFieldCount?.(0); stateUpdate.body = this.buildClearFiltersLayout(() => this.clearVariables(variablesToClear)); } else { stateUpdate.body = new EmptyLayoutScene({ type: 'fields' }); @@ -270,8 +276,13 @@ export class FieldsBreakdownScene extends SceneObjectBase { if (variable instanceof AdHocFiltersVariable && variable.state.key === 'adhoc_service_filter') { + let { labelName } = getPrimaryLabelFromUrl(); + // getPrimaryLabelFromUrl returns the label name that exists in the URL, which is "service" not "service_name" + if (labelName === VAR_SERVICE) { + labelName = SERVICE_NAME; + } variable.setState({ - filters: variable.state.filters.filter((filter) => filter.key === SERVICE_NAME), + filters: variable.state.filters.filter((filter) => filter.key === labelName), }); } else if (variable instanceof AdHocFiltersVariable) { variable.setState({ diff --git a/src/Components/ServiceScene/Breakdowns/LabelBreakdownScene.test.ts b/src/Components/ServiceScene/Breakdowns/LabelBreakdownScene.test.ts index 4b3fb4cd..7aa0a0f0 100644 --- a/src/Components/ServiceScene/Breakdowns/LabelBreakdownScene.test.ts +++ b/src/Components/ServiceScene/Breakdowns/LabelBreakdownScene.test.ts @@ -12,7 +12,7 @@ describe('buildLabelsQuery', () => { const result = buildLabelsQuery({} as SceneObject, VAR_LABEL_GROUP_BY_EXPR, 'cluster'); expect(result).toMatchObject({ - expr: `sum(count_over_time({\${filters} ,cluster != ""} \${levels} \${patterns} \${lineFilter} [$__auto])) by (${VAR_LABEL_GROUP_BY_EXPR})`, + expr: `sum(count_over_time({\${filters} ,cluster != ""} \${levels} \${patterns} \${lineFilter} \${fields} [$__auto])) by (${VAR_LABEL_GROUP_BY_EXPR})`, }); }); test('should build no-parser query with structured metadata filters', () => { @@ -35,7 +35,7 @@ describe('buildLabelsQuery', () => { const result = buildLabelsQuery({} as SceneObject, VAR_LABEL_GROUP_BY_EXPR, 'cluster'); expect(result).toMatchObject({ - expr: `sum(count_over_time({\${filters} ,cluster != ""} \${levels} \${patterns} \${lineFilter} [$__auto])) by (${VAR_LABEL_GROUP_BY_EXPR})`, + expr: `sum(count_over_time({\${filters} ,cluster != ""} \${levels} \${patterns} \${lineFilter} \${fields} [$__auto])) by (${VAR_LABEL_GROUP_BY_EXPR})`, }); }); test('should build logfmt-parser query with structured metadata filters', () => { diff --git a/src/Components/ServiceScene/Breakdowns/LabelBreakdownScene.tsx b/src/Components/ServiceScene/Breakdowns/LabelBreakdownScene.tsx index 3051b2c1..11133a60 100644 --- a/src/Components/ServiceScene/Breakdowns/LabelBreakdownScene.tsx +++ b/src/Components/ServiceScene/Breakdowns/LabelBreakdownScene.tsx @@ -16,8 +16,8 @@ import { } from '@grafana/scenes'; import { Alert, useStyles2 } from '@grafana/ui'; import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from 'services/analytics'; -import { ValueSlugs } from 'services/routing'; -import { ALL_VARIABLE_VALUE, SERVICE_NAME, VAR_LABEL_GROUP_BY, VAR_LABELS } from 'services/variables'; +import { checkPrimaryLabel, getPrimaryLabelFromUrl, ValueSlugs } from 'services/routing'; +import { ALL_VARIABLE_VALUE, SERVICE_NAME, VAR_LABEL_GROUP_BY, VAR_LABELS, VAR_SERVICE } from 'services/variables'; import { ByFrameRepeater } from './ByFrameRepeater'; import { FieldSelector } from './FieldSelector'; import { StatusWrapper } from './StatusWrapper'; @@ -113,6 +113,8 @@ export class LabelBreakdownScene extends SceneObjectBase filter.key === SERVICE_NAME); - const prevService = prevState.filters.find((filter) => filter.key === SERVICE_NAME); + const newPrimaryLabel = newState.filters.find((filter) => filter.key === labelName); + const prevPrimaryLabel = prevState.filters.find((filter) => filter.key === labelName); // If the user changes the service - if (variable.state.value === ALL_VARIABLE_VALUE && newService !== prevService) { + if (variable.state.value === ALL_VARIABLE_VALUE && newPrimaryLabel !== prevPrimaryLabel) { this.setState({ loading: true, body: undefined, diff --git a/src/Components/ServiceScene/Breakdowns/SelectLabelActionScene.tsx b/src/Components/ServiceScene/Breakdowns/SelectLabelActionScene.tsx index 86f06548..06ad2a9a 100644 --- a/src/Components/ServiceScene/Breakdowns/SelectLabelActionScene.tsx +++ b/src/Components/ServiceScene/Breakdowns/SelectLabelActionScene.tsx @@ -8,12 +8,12 @@ import { } from '@grafana/scenes'; import { getLogsPanelFrame, ServiceScene } from '../ServiceScene'; import { navigateToValueBreakdown } from '../../../services/navigate'; -import { ValueSlugs } from '../../../services/routing'; +import { getPrimaryLabelFromUrl, ValueSlugs } from '../../../services/routing'; import { Button } from '@grafana/ui'; import React from 'react'; import { addToFilters, VariableFilterType } from './AddToFiltersButton'; import { FilterButton } from '../../FilterButton'; -import { EMPTY_VARIABLE_VALUE, LEVEL_VARIABLE_VALUE, SERVICE_NAME } from '../../../services/variables'; +import { EMPTY_VARIABLE_VALUE, LEVEL_VARIABLE_VALUE } from '../../../services/variables'; import { AdHocVariableFilter, Field, Labels, LoadingState } from '@grafana/data'; import { FilterOp } from '../../../services/filters'; import { @@ -78,7 +78,8 @@ export class SelectLabelActionScene extends SceneObjectBase { const value = getValueFromAdHocVariableFilter(variable, filter); return filter.key === this.state.labelName && value.value === EMPTY_VARIABLE_VALUE; diff --git a/src/Components/ServiceScene/LogsPanelScene.tsx b/src/Components/ServiceScene/LogsPanelScene.tsx index 1b0563a5..f036460f 100644 --- a/src/Components/ServiceScene/LogsPanelScene.tsx +++ b/src/Components/ServiceScene/LogsPanelScene.tsx @@ -15,7 +15,7 @@ import { LogsListScene } from './LogsListScene'; import { LoadingPlaceholder } from '@grafana/ui'; import { addToFilters, FilterType } from './Breakdowns/AddToFiltersButton'; import { getFilterTypeFromLabelType, getLabelTypeFromFrame } from '../../services/fields'; -import { SERVICE_NAME, VAR_FIELDS, VAR_LABELS, VAR_LEVELS } from '../../services/variables'; +import { VAR_FIELDS, VAR_LABELS, VAR_LEVELS } from '../../services/variables'; import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from '../../services/analytics'; import { getAdHocFiltersVariable } from '../../services/variableGetters'; import { LabelType } from '../../services/fieldsTypes'; @@ -169,10 +169,6 @@ export class LogsPanelScene extends SceneObjectBase { }; private handleLabelFilter(key: string, value: string, frame: DataFrame | undefined, operator: FilterType) { - // @TODO: NOOP. We need a way to let the user know why this is not possible. - if (key === SERVICE_NAME) { - return; - } const labelType = frame ? getLabelTypeFromFrame(key, frame) : LabelType.Parsed; const variableName = getFilterTypeFromLabelType(labelType, key, value); addToFilters(key, value, operator, this, variableName); diff --git a/src/Components/ServiceScene/ServiceScene.tsx b/src/Components/ServiceScene/ServiceScene.tsx index 47945602..1ab0daea 100644 --- a/src/Components/ServiceScene/ServiceScene.tsx +++ b/src/Components/ServiceScene/ServiceScene.tsx @@ -18,8 +18,8 @@ import { import { LoadingPlaceholder } from '@grafana/ui'; import { getQueryRunner, getResourceQueryRunner } from 'services/panel'; import { buildDataQuery, buildResourceQuery } from 'services/query'; -import { getDrilldownSlug, getDrilldownValueSlug, PageSlugs, ValueSlugs } from 'services/routing'; import { + EMPTY_VARIABLE_VALUE, LEVEL_VARIABLE_VALUE, LOG_STREAM_SELECTOR_EXPR, SERVICE_NAME, @@ -29,9 +29,10 @@ import { VAR_LABELS_EXPR, VAR_LEVELS, VAR_PATTERNS, + VAR_SERVICE, } from 'services/variables'; import { getMetadataService } from '../../services/metadata'; -import { navigateToIndex } from '../../services/navigate'; +import { navigateToDrilldownPage, navigateToIndex, navigateToValueBreakdown } from '../../services/navigate'; import { areArraysEqual } from '../../services/comparison'; import { ActionBarScene } from './ActionBarScene'; import { breakdownViewsDefinitions, TabNames, valueBreakdownViews } from './BreakdownViews'; @@ -40,9 +41,17 @@ import { getFieldsVariable, getLabelsVariable, getLevelsVariable, - getServiceNameFromVariableState, } from '../../services/variableGetters'; import { logger } from '../../services/logger'; +import { IndexScene } from '../IndexScene/IndexScene'; +import { + getDrilldownSlug, + getDrilldownValueSlug, + getPrimaryLabelFromUrl, + PageSlugs, + ValueSlugs, +} from '../../services/routing'; +import { replaceSlash } from '../../services/extensions/links'; const LOGS_PANEL_QUERY_REFID = 'logsPanelQuery'; const PATTERNS_QUERY_REFID = 'patterns'; @@ -134,22 +143,46 @@ export class ServiceScene extends SceneObjectBase { } this._subs.add( variable.subscribeToState((newState, prevState) => { - const newServiceName = getServiceNameFromVariableState(newState); - const prevServiceName = getServiceNameFromVariableState(prevState); if (newState.filters.length === 0) { this.redirectToStart(); } // If we remove the service name filter, we should redirect to the start - if (!newState.filters.some((f) => f.key === SERVICE_NAME)) { - this.redirectToStart(); - } + let { labelName, labelValue, breakdownLabel } = getPrimaryLabelFromUrl(); - // Clear filters if changing service, they might not exist, or might have a different parser - if (prevServiceName !== newServiceName) { - const fields = getFieldsVariable(this); - fields.setState({ - filters: [], - }); + // Before we dynamically pulled label filter keys into the URL, we had hardcoded "service" as the primary label slug, we want to keep URLs the same, so overwrite "service_name" with "service" if that's the primary label + if (labelName === VAR_SERVICE) { + labelName = SERVICE_NAME; + } + const indexScene = sceneGraph.getAncestor(this, IndexScene); + const prevRouteMatch = indexScene.state.routeMatch; + + // The "primary" label used in the URL is no longer active, pick a new one + if (!newState.filters.some((f) => f.key === labelName && f.operator === '=' && f.value === labelValue)) { + const newPrimaryLabel = newState.filters.find((f) => f.operator === '=' && f.value !== EMPTY_VARIABLE_VALUE); + if (newPrimaryLabel) { + indexScene.setState({ + routeMatch: { + ...prevRouteMatch, + params: { + ...prevRouteMatch?.params, + //@todo clean up usage of VAR_SERVICE in child branch + labelName: newPrimaryLabel.key === SERVICE_NAME ? VAR_SERVICE : newPrimaryLabel.key, + labelValue: replaceSlash(newPrimaryLabel.value), + }, + url: prevRouteMatch?.url ?? '', + path: prevRouteMatch?.path ?? '', + isExact: prevRouteMatch?.isExact ?? true, + }, + }); + + if (!breakdownLabel) { + navigateToDrilldownPage(getDrilldownSlug(), this); + } else { + navigateToValueBreakdown(getDrilldownValueSlug(), breakdownLabel, this); + } + } else { + this.redirectToStart(); + } } }) ); @@ -336,6 +369,7 @@ export class ServiceScene extends SceneObjectBase { if (newState.data?.state === LoadingState.Done) { const detectedFieldsResponse = newState.data; const detectedFieldsFields = detectedFieldsResponse.series[0]; + if (detectedFieldsFields !== undefined && detectedFieldsFields.length !== this.state.fieldsCount) { this.setState({ fieldsCount: detectedFieldsFields.length, diff --git a/src/services/datasource.ts b/src/services/datasource.ts index 950112e2..5d4a68c3 100644 --- a/src/services/datasource.ts +++ b/src/services/datasource.ts @@ -13,9 +13,9 @@ import { RuntimeDataSource, sceneUtils } from '@grafana/scenes'; import { DataQuery } from '@grafana/schema'; import { Observable, Subscriber } from 'rxjs'; import { getDataSource } from './scenes'; -import { PLUGIN_ID } from './routing'; +import { getPrimaryLabelFromUrl, PLUGIN_ID } from './routing'; import { DetectedFieldsResponse, DetectedLabelsResponse } from './fields'; -import { FIELDS_TO_REMOVE, LABELS_TO_REMOVE, sortLabelsByCardinality } from './filters'; +import { FIELDS_TO_REMOVE, sortLabelsByCardinality } from './filters'; import { SERVICE_NAME } from './variables'; import { runShardSplitQuery } from './shardQuerySplitting'; import { requestSupportsSharding } from './logql'; @@ -290,8 +290,11 @@ export class WrappedLokiDatasource extends RuntimeDataSource { }, } ); + + const { labelName: primaryLabelName } = getPrimaryLabelFromUrl(); + const labels = response.detectedLabels - ?.filter((label) => !LABELS_TO_REMOVE.includes(label.label)) + ?.filter((label) => primaryLabelName !== label.label) ?.sort((a, b) => sortLabelsByCardinality(a, b)); const detectedLabelFields: Array> = labels?.map((label) => { diff --git a/src/services/expressions.ts b/src/services/expressions.ts index a0d00acf..16fd3786 100644 --- a/src/services/expressions.ts +++ b/src/services/expressions.ts @@ -1,17 +1,16 @@ import { - LEVEL_VARIABLE_VALUE, - VAR_FIELDS_EXPR, JSON_FORMAT_EXPR, - VAR_LINE_FILTER_EXPR, + LEVEL_VARIABLE_VALUE, LOGS_FORMAT_EXPR, MIXED_FORMAT_EXPR, + VAR_FIELDS_EXPR, + VAR_LABELS_EXPR, + VAR_LINE_FILTER_EXPR, VAR_PATTERNS_EXPR, } from './variables'; -import { isDefined } from './scenes'; import { SceneObject } from '@grafana/scenes'; -import { renderLogQLLabelFilters } from './query'; import { getParserFromFieldsFilters } from './fields'; -import { getFieldsVariable, getLabelsVariable } from './variableGetters'; +import { getFieldsVariable } from './variableGetters'; /** * Crafts count over time query that excludes empty values for stream selector name @@ -21,37 +20,30 @@ import { getFieldsVariable, getLabelsVariable } from './variableGetters'; * @param excludeEmpty - if true, the query will exclude empty values for the given streamSelectorName */ export function getTimeSeriesExpr(sceneRef: SceneObject, streamSelectorName: string, excludeEmpty = true): string { - const labelsVariable = getLabelsVariable(sceneRef); const fieldsVariable = getFieldsVariable(sceneRef); - let labelExpressionToAdd; let metadataExpressionToAdd = ''; if (excludeEmpty) { // `LEVEL_VARIABLE_VALUE` is a special case where we don't want to add this to the stream selector - if (streamSelectorName !== LEVEL_VARIABLE_VALUE) { - labelExpressionToAdd = { key: streamSelectorName, operator: '!=', value: '' }; - } else { + if (streamSelectorName === LEVEL_VARIABLE_VALUE) { metadataExpressionToAdd = `| ${LEVEL_VARIABLE_VALUE} != ""`; } } - const labelFilters = [...labelsVariable.state.filters, labelExpressionToAdd].filter(isDefined); - const streamSelectors = renderLogQLLabelFilters(labelFilters); - const fieldFilters = fieldsVariable.state.filters; const parser = getParserFromFieldsFilters(fieldsVariable); // if we have fields, we also need to add parsers if (fieldFilters.length) { if (parser === 'mixed') { - return `sum(count_over_time({${streamSelectors}} ${metadataExpressionToAdd} ${VAR_LINE_FILTER_EXPR} ${VAR_PATTERNS_EXPR} ${MIXED_FORMAT_EXPR} ${VAR_FIELDS_EXPR} [$__auto])) by (${streamSelectorName})`; + return `sum(count_over_time({${VAR_LABELS_EXPR}} ${metadataExpressionToAdd} ${VAR_LINE_FILTER_EXPR} ${VAR_PATTERNS_EXPR} ${MIXED_FORMAT_EXPR} ${VAR_FIELDS_EXPR} [$__auto])) by (${streamSelectorName})`; } if (parser === 'json') { - return `sum(count_over_time({${streamSelectors}} ${metadataExpressionToAdd} ${VAR_LINE_FILTER_EXPR} ${VAR_PATTERNS_EXPR} ${JSON_FORMAT_EXPR} ${VAR_FIELDS_EXPR} [$__auto])) by (${streamSelectorName})`; + return `sum(count_over_time({${VAR_LABELS_EXPR}} ${metadataExpressionToAdd} ${VAR_LINE_FILTER_EXPR} ${VAR_PATTERNS_EXPR} ${JSON_FORMAT_EXPR} ${VAR_FIELDS_EXPR} [$__auto])) by (${streamSelectorName})`; } if (parser === 'logfmt') { - return `sum(count_over_time({${streamSelectors}} ${metadataExpressionToAdd} ${VAR_LINE_FILTER_EXPR} ${VAR_PATTERNS_EXPR} ${LOGS_FORMAT_EXPR} ${VAR_FIELDS_EXPR} [$__auto])) by (${streamSelectorName})`; + return `sum(count_over_time({${VAR_LABELS_EXPR}} ${metadataExpressionToAdd} ${VAR_LINE_FILTER_EXPR} ${VAR_PATTERNS_EXPR} ${LOGS_FORMAT_EXPR} ${VAR_FIELDS_EXPR} [$__auto])) by (${streamSelectorName})`; } } - return `sum(count_over_time({${streamSelectors}} ${metadataExpressionToAdd} ${VAR_LINE_FILTER_EXPR} ${VAR_PATTERNS_EXPR} [$__auto])) by (${streamSelectorName})`; + return `sum(count_over_time({${VAR_LABELS_EXPR}} ${metadataExpressionToAdd} ${VAR_LINE_FILTER_EXPR} ${VAR_PATTERNS_EXPR} ${VAR_FIELDS_EXPR} [$__auto])) by (${streamSelectorName})`; } diff --git a/src/services/extensions/links.ts b/src/services/extensions/links.ts index 64a4ce3b..cddb605d 100644 --- a/src/services/extensions/links.ts +++ b/src/services/extensions/links.ts @@ -6,6 +6,7 @@ import pluginJson from '../../plugin.json'; import { LokiQuery } from '../lokiQuery'; import { getMatcherFromQuery } from '../logqlMatchers'; import { LabelType } from '../fieldsTypes'; +import { FilterOp } from '../filters'; const title = 'Open in Explore Logs'; const description = 'Open current query in the Explore Logs view'; @@ -41,13 +42,17 @@ function contextToLink(context?: T) { const expr = lokiQuery.expr; const labelFilters = getMatcherFromQuery(expr); - const serviceSelector = labelFilters.find((selector) => selector.key === SERVICE_NAME); - if (!serviceSelector) { + + const labelSelector = labelFilters.find((selector) => selector.operator === FilterOp.Equal); + + if (!labelSelector) { return undefined; } - const serviceName = replaceSlash(serviceSelector.value); - // sort `service_name` first - labelFilters.sort((a, b) => (a.key === SERVICE_NAME ? -1 : 1)); + + const labelValue = replaceSlash(labelSelector.value); + let labelName = labelSelector.key === SERVICE_NAME ? 'service' : labelSelector.key; + // sort `primary label` first + labelFilters.sort((a, b) => (a.key === labelName ? -1 : 1)); let params = setUrlParameter(UrlParameters.DatasourceId, lokiQuery.datasource?.uid); params = setUrlParameter(UrlParameters.TimeRangeFrom, context.timeRange.from.valueOf().toString(), params); @@ -65,9 +70,8 @@ function contextToLink(context?: T) { params ); } - return { - path: createAppUrl(`/explore/service/${serviceName}/logs`, params), + path: createAppUrl(`/explore/${labelName}/${labelValue}/logs`, params), }; } diff --git a/src/services/filters.ts b/src/services/filters.ts index a0522093..2707a6fd 100644 --- a/src/services/filters.ts +++ b/src/services/filters.ts @@ -1,5 +1,5 @@ import { DetectedLabel } from './fields'; -import { ALL_VARIABLE_VALUE, LEVEL_VARIABLE_VALUE, SERVICE_NAME } from './variables'; +import { ALL_VARIABLE_VALUE, LEVEL_VARIABLE_VALUE } from './variables'; import { VariableValueOption } from '@grafana/scenes'; import { LabelType } from './fieldsTypes'; @@ -44,8 +44,6 @@ export function getLabelOptions(labels: string[]) { } export const LEVEL_INDEX_NAME = 'level'; export const FIELDS_TO_REMOVE = ['level_extracted', LEVEL_VARIABLE_VALUE, LEVEL_INDEX_NAME]; -export const LABELS_TO_REMOVE = [SERVICE_NAME]; - export function getFieldOptions(labels: string[]) { const options = [...labels]; const labelOptions: VariableValueOption[] = options.map((label) => ({ diff --git a/src/services/navigate.test.ts b/src/services/navigate.test.ts index 372c3da5..ec189e9a 100644 --- a/src/services/navigate.test.ts +++ b/src/services/navigate.test.ts @@ -35,8 +35,9 @@ describe('navigate', () => { isExact: true, url: '', params: { - service: serviceLabel, - label: drillDownLabel, + labelValue: serviceLabel, + labelName: 'service', + breakdownLabel: drillDownLabel, }, }, }, @@ -69,7 +70,8 @@ describe('navigate', () => { isExact: true, url: '', params: { - service: serviceLabel, + labelName: 'service', + labelValue: serviceLabel, }, }, }, diff --git a/src/services/navigate.ts b/src/services/navigate.ts index a2e73028..26e85cc0 100644 --- a/src/services/navigate.ts +++ b/src/services/navigate.ts @@ -7,14 +7,17 @@ import { buildServicesUrl, DRILLDOWN_URL_KEYS, PageSlugs, prefixRoute, ROUTES, V import { sceneGraph } from '@grafana/scenes'; import { UrlQueryMap, urlUtil } from '@grafana/data'; import { replaceSlash } from './extensions/links'; +import { logger } from './logger'; -function buildValueBreakdownUrl(label: string, newPath: ValueSlugs, serviceString: string) { +function buildValueBreakdownUrl(label: string, newPath: ValueSlugs, labelValue: string, labelName = 'service') { if (label === ALL_VARIABLE_VALUE && newPath === ValueSlugs.label) { - return prefixRoute(`${PageSlugs.explore}/service/${replaceSlash(serviceString)}/${PageSlugs.labels}`); + return prefixRoute(`${PageSlugs.explore}/${labelName}/${replaceSlash(labelValue)}/${PageSlugs.labels}`); } else if (label === ALL_VARIABLE_VALUE && newPath === ValueSlugs.field) { - return prefixRoute(`${PageSlugs.explore}/service/${replaceSlash(serviceString)}/${PageSlugs.fields}`); + return prefixRoute(`${PageSlugs.explore}/${labelName}/${replaceSlash(labelValue)}/${PageSlugs.fields}`); } else { - return prefixRoute(`${PageSlugs.explore}/service/${replaceSlash(serviceString)}/${newPath}/${replaceSlash(label)}`); + return prefixRoute( + `${PageSlugs.explore}/${labelName}/${replaceSlash(labelValue)}/${newPath}/${replaceSlash(label)}` + ); } } @@ -45,9 +48,10 @@ export function navigateToValueBreakdown(newPath: ValueSlugs, label: string, ser const indexScene = sceneGraph.getAncestor(serviceScene, IndexScene); if (indexScene) { - const serviceString = indexScene.state.routeMatch?.params.service; - if (serviceString) { - let urlPath = buildValueBreakdownUrl(label, newPath, serviceString); + const urlLabelName = indexScene.state.routeMatch?.params.labelName; + const urlLabelValue = indexScene.state.routeMatch?.params.labelValue; + if (urlLabelName && urlLabelValue) { + let urlPath = buildValueBreakdownUrl(label, newPath, urlLabelValue, urlLabelName); const fullUrl = buildDrilldownPageUrl(urlPath); // If we're going to navigate, we need to share the state between this instantiation of the service scene @@ -58,6 +62,11 @@ export function navigateToValueBreakdown(newPath: ValueSlugs, label: string, ser locationService.push(fullUrl); return; + } else { + logger.warn('missing url params', { + urlLabelName: urlLabelName ?? '', + urlLabelValue: urlLabelValue ?? '', + }); } } } @@ -81,10 +90,11 @@ export function navigateToInitialPageAfterServiceSelection(serviceName: string) */ export function navigateToDrilldownPage(path: PageSlugs, serviceScene: ServiceScene, extraQueryParams?: UrlQueryMap) { const indexScene = sceneGraph.getAncestor(serviceScene, IndexScene); - const serviceString = indexScene.state.routeMatch?.params.service; + const urlLabelValue = indexScene.state.routeMatch?.params.labelValue; + const urlLabelName = indexScene.state.routeMatch?.params.labelName; - if (serviceString) { - const fullUrl = prefixRoute(`${PageSlugs.explore}/service/${replaceSlash(serviceString)}/${path}`); + if (urlLabelValue) { + const fullUrl = prefixRoute(`${PageSlugs.explore}/${urlLabelName}/${replaceSlash(urlLabelValue)}/${path}`); const breakdownUrl = buildDrilldownPageUrl(fullUrl, extraQueryParams); // If we're going to navigate, we need to share the state between this instantiation of the service scene diff --git a/src/services/routing.ts b/src/services/routing.ts index d52e7629..b1378e91 100644 --- a/src/services/routing.ts +++ b/src/services/routing.ts @@ -1,6 +1,7 @@ import pluginJson from '../plugin.json'; import { UrlQueryMap, urlUtil } from '@grafana/data'; import { + SERVICE_NAME, VAR_DATASOURCE, VAR_FIELD_GROUP_BY, VAR_FIELDS, @@ -9,10 +10,14 @@ import { VAR_LEVELS, VAR_LINE_FILTER, VAR_PATTERNS, + VAR_SERVICE, } from './variables'; import { locationService } from '@grafana/runtime'; -import { SceneRouteMatch } from '@grafana/scenes'; +import { RouteMatch, RouteProps } from '../Components/Pages'; import { replaceSlash } from './extensions/links'; +import { getLabelsVariable } from './variableGetters'; +import { logger } from './logger'; +import { SceneObject } from '@grafana/scenes'; export const PLUGIN_ID = pluginJson.id; export const PLUGIN_BASE_URL = `/a/${PLUGIN_ID}`; @@ -39,31 +44,38 @@ export type ChildDrilldownSlugs = ValueSlugs.field | ValueSlugs.label; export const ROUTES = { explore: () => prefixRoute(PageSlugs.explore), - logs: (service: string) => prefixRoute(`${PageSlugs.explore}/service/${replaceSlash(service)}/${PageSlugs.logs}`), - fields: (service: string) => prefixRoute(`${PageSlugs.explore}/service/${replaceSlash(service)}/${PageSlugs.fields}`), - patterns: (service: string) => - prefixRoute(`${PageSlugs.explore}/service/${replaceSlash(service)}/${PageSlugs.patterns}`), - labels: (service: string) => prefixRoute(`${PageSlugs.explore}/service/${replaceSlash(service)}/${PageSlugs.labels}`), + logs: (labelValue: string, labelName = 'service') => + prefixRoute(`${PageSlugs.explore}/${labelName}/${replaceSlash(labelValue)}/${PageSlugs.logs}`), + fields: (labelValue: string, labelName = 'service') => + prefixRoute(`${PageSlugs.explore}/${labelName}/${replaceSlash(labelValue)}/${PageSlugs.fields}`), + patterns: (labelValue: string, labelName = 'service') => + prefixRoute(`${PageSlugs.explore}/${labelName}/${replaceSlash(labelValue)}/${PageSlugs.patterns}`), + labels: (labelValue: string, labelName = 'service') => + prefixRoute(`${PageSlugs.explore}/${labelName}/${replaceSlash(labelValue)}/${PageSlugs.labels}`), }; export const SUB_ROUTES = { - label: (service: string, label: string) => - prefixRoute(`${PageSlugs.explore}/service/${replaceSlash(service)}/${ValueSlugs.label}/${label}`), - field: (service: string, label: string) => - prefixRoute(`${PageSlugs.explore}/service/${replaceSlash(service)}/${ValueSlugs.field}/${label}`), + label: (labelValue: string, labelName = 'service', breakdownLabelName: string) => + prefixRoute( + `${PageSlugs.explore}/${labelName}/${replaceSlash(labelValue)}/${ValueSlugs.label}/${breakdownLabelName}` + ), + field: (labelValue: string, labelName = 'service', breakdownLabelName: string) => + prefixRoute( + `${PageSlugs.explore}/${labelName}/${replaceSlash(labelValue)}/${ValueSlugs.field}/${breakdownLabelName}` + ), }; export const ROUTE_DEFINITIONS: Record = { explore: prefixRoute(PageSlugs.explore), - logs: prefixRoute(`${PageSlugs.explore}/service/:service/${PageSlugs.logs}`), - fields: prefixRoute(`${PageSlugs.explore}/service/:service/${PageSlugs.fields}`), - patterns: prefixRoute(`${PageSlugs.explore}/service/:service/${PageSlugs.patterns}`), - labels: prefixRoute(`${PageSlugs.explore}/service/:service/${PageSlugs.labels}`), + logs: prefixRoute(`${PageSlugs.explore}/:labelName/:labelValue/${PageSlugs.logs}`), + fields: prefixRoute(`${PageSlugs.explore}/:labelName/:labelValue/${PageSlugs.fields}`), + patterns: prefixRoute(`${PageSlugs.explore}/:labelName/:labelValue/${PageSlugs.patterns}`), + labels: prefixRoute(`${PageSlugs.explore}/:labelName/:labelValue/${PageSlugs.labels}`), }; export const CHILD_ROUTE_DEFINITIONS: Record = { - field: prefixRoute(`${PageSlugs.explore}/service/:service/${ValueSlugs.field}/:label`), - label: prefixRoute(`${PageSlugs.explore}/service/:service/${ValueSlugs.label}/:label`), + field: prefixRoute(`${PageSlugs.explore}/:labelName/:labelValue/${ValueSlugs.field}/:breakdownLabel`), + label: prefixRoute(`${PageSlugs.explore}/:labelName/:labelValue/${ValueSlugs.label}/:breakdownLabel`), }; export const EXPLORATIONS_ROUTE = `${PLUGIN_BASE_URL}/${PageSlugs.explore}`; @@ -102,6 +114,27 @@ export function getDrilldownSlug() { return slug as PageSlugs; } +/** + * The "primary" label, is the replacement for the service_name paradigm + * It must be an indexed label with an include filter + * Note: Will return the label as it exists in the url, so "service_name" will be returned as "service", we'll need to adjust for this case if we want to support URLs from before this change + */ +export function getPrimaryLabelFromUrl(): RouteProps { + const location = locationService.getLocation(); + const startOfUrl = '/a/grafana-lokiexplore-app/explore'; + const endOfUrl = location.pathname.slice(location.pathname.indexOf(startOfUrl) + startOfUrl.length + 1); + const routeParams = endOfUrl.split('/'); + + let labelName = routeParams[0]; + const labelValue = routeParams[1]; + const breakdownLabel = routeParams[3]; + // Keep urls the same + if (labelName === SERVICE_NAME) { + labelName = VAR_SERVICE; + } + return { labelName, labelValue, breakdownLabel }; +} + export function getDrilldownValueSlug() { const location = locationService.getLocation(); const locationArray = location.pathname.split('/'); @@ -112,14 +145,12 @@ export function getDrilldownValueSlug() { export function buildServicesUrl(path: string, extraQueryParams?: UrlQueryMap): string { return urlUtil.renderUrl(path, buildServicesRoute(extraQueryParams)); } -export function extractServiceFromRoute(routeMatch: SceneRouteMatch<{ service: string }>): { service: string } { - const service = routeMatch.params.service; - return { service }; -} - -export function extractLabelNameFromRoute(routeMatch: SceneRouteMatch<{ label: string }>): { label: string } { - const label = routeMatch.params.label; - return { label }; +export function extractValuesFromRoute(routeMatch: RouteMatch): RouteProps { + return { + labelName: routeMatch.params.labelName, + labelValue: routeMatch.params.labelValue, + breakdownLabel: routeMatch.params.breakdownLabel, + }; } export function buildServicesRoute(extraQueryParams?: UrlQueryMap): UrlQueryMap { @@ -134,3 +165,36 @@ export function buildServicesRoute(extraQueryParams?: UrlQueryMap): UrlQueryMap ...extraQueryParams, }; } + +/** + * Compare slugs against variable filters and log discrepancies + * These don't cause errors or render empty UIs, but shouldn't be possible when routing within the app + * If we see these logged in production it indicates we're navigating users incorrectly + * @param sceneRef + */ +export function checkPrimaryLabel(sceneRef: SceneObject) { + const labelsVariable = getLabelsVariable(sceneRef); + let { labelName, labelValue } = getPrimaryLabelFromUrl(); + if (labelName === VAR_SERVICE) { + labelName = SERVICE_NAME; + } + const primaryLabel = labelsVariable.state.filters.find((filter) => filter.key === labelName); + if (!primaryLabel) { + const location = locationService.getLocation(); + + logger.info('invalid primary label name in url', { + labelName, + url: `${location.pathname}${location.search}`, + }); + } + + const primaryLabelValue = labelsVariable.state.filters.find((filter) => filter.value === labelValue); + if (!primaryLabelValue) { + const location = locationService.getLocation(); + + logger.info('invalid primary label value in url', { + labelValue, + url: `${location.pathname}${location.search}`, + }); + } +} diff --git a/src/services/variableGetters.ts b/src/services/variableGetters.ts index a0a7ffaa..b76f4047 100644 --- a/src/services/variableGetters.ts +++ b/src/services/variableGetters.ts @@ -44,7 +44,7 @@ export function getLogsStreamSelector(options: LogsQueryOptions) { switch (parser) { case 'structuredMetadata': - return `{${VAR_LABELS_EXPR}${labelExpressionToAdd}} ${structuredMetadataToAdd} ${VAR_LEVELS_EXPR} ${VAR_PATTERNS_EXPR} ${VAR_LINE_FILTER_EXPR}`; + return `{${VAR_LABELS_EXPR}${labelExpressionToAdd}} ${structuredMetadataToAdd} ${VAR_LEVELS_EXPR} ${VAR_PATTERNS_EXPR} ${VAR_LINE_FILTER_EXPR} ${fieldExpressionToAdd} ${VAR_FIELDS_EXPR}`; case 'json': return `{${VAR_LABELS_EXPR}${labelExpressionToAdd}} ${structuredMetadataToAdd} ${VAR_LEVELS_EXPR} ${VAR_PATTERNS_EXPR} ${VAR_LINE_FILTER_EXPR} ${JSON_FORMAT_EXPR} ${fieldExpressionToAdd} ${VAR_FIELDS_EXPR}`; case 'logfmt': diff --git a/tests/exploreServicesBreakDown.spec.ts b/tests/exploreServicesBreakDown.spec.ts index 6532838f..5b3bb183 100644 --- a/tests/exploreServicesBreakDown.spec.ts +++ b/tests/exploreServicesBreakDown.spec.ts @@ -35,6 +35,65 @@ test.describe('explore services breakdown page', () => { await expect(page).toHaveURL(/broadcast/); }); + test(`should replace service_name with ${labelName} in url`, async ({ page }) => { + explorePage.blockAllQueriesExcept({ + refIds: ['logsPanelQuery'], + legendFormats: [`{{${labelName}}}`, `{{service_name}}`], + }); + await explorePage.goToLabelsTab(); + + // Select cluster + const selectClusterButton = page.getByLabel(`Select ${labelName}`); + await expect(selectClusterButton).toHaveCount(1); + await page.getByLabel(`Select ${labelName}`).click(); + + // exclude "us-east-1" cluster + const excludeCluster = 'us-east-1'; + const clusterExcludeSelectButton = page + .getByTestId(`data-testid Panel header ${excludeCluster}`) + .getByTestId('data-testid button-filter-exclude'); + await expect(clusterExcludeSelectButton).toHaveCount(1); + await clusterExcludeSelectButton.click(); + + // include eu-west-1 cluster + const includeCluster = 'eu-west-1'; + const clusterIncludeSelectButton = page + .getByTestId(`data-testid Panel header ${includeCluster}`) + .getByTestId('data-testid button-filter-include'); + await expect(clusterIncludeSelectButton).toHaveCount(1); + await clusterIncludeSelectButton.click(); + + // Include should navigate us back to labels tab + await explorePage.assertTabsNotLoading(); + await expect(selectClusterButton).toHaveCount(1); + + // Now remove service_name variable + const removeServiceNameFilterBtn = page + .getByTestId('data-testid Dashboard template variables submenu Label service_name') + .getByLabel('Remove'); + await expect(removeServiceNameFilterBtn).toHaveCount(1); + await removeServiceNameFilterBtn.click(); + + // Assert cluster has been added as the new URL slug + await explorePage.assertTabsNotLoading(); + await expect(page).toHaveURL(/\/cluster\/eu-west-1\//); + + // Assert service_name is visible as a normal label + const serviceNameSelect = page.getByLabel('Select service_name'); + await expect(serviceNameSelect).toHaveCount(1); + await serviceNameSelect.click(); + + // exclude nginx service + const nginxExcludeBtn = page + .getByTestId('data-testid Panel header nginx') + .getByTestId('data-testid button-filter-exclude'); + await expect(nginxExcludeBtn).toHaveCount(1); + await nginxExcludeBtn.click(); + + const serviceNameFilter = page.getByTestId('data-testid Dashboard template variables submenu Label service_name'); + await expect(serviceNameFilter).toHaveCount(1); + }); + test('logs panel should have panel-content class suffix', async ({ page }) => { await explorePage.serviceBreakdownSearch.click(); await explorePage.serviceBreakdownSearch.fill('broadcast');