diff --git a/src/platform/packages/shared/kbn-grok-ui/components/expression.tsx b/src/platform/packages/shared/kbn-grok-ui/components/expression.tsx index a8401f0de10a5..d001304145b90 100644 --- a/src/platform/packages/shared/kbn-grok-ui/components/expression.tsx +++ b/src/platform/packages/shared/kbn-grok-ui/components/expression.tsx @@ -10,18 +10,28 @@ import type { CodeEditorProps, monaco } from '@kbn/code-editor'; import { CodeEditor } from '@kbn/code-editor'; import React, { useRef, useState, useEffect, useMemo } from 'react'; +import { css } from '@emotion/react'; +import { useEuiTheme } from '@elastic/eui'; import { useResizeChecker } from '@kbn/react-hooks'; import { DraftGrokExpression, type GrokCollection } from '../models'; +import { colourToClassName } from './utils'; + +// Matches %{SYNTAX:SEMANTIC} and %{SYNTAX:SEMANTIC:TYPE} tokens +const GROK_FIELD_PATTERN_REGEX = + /%\{[A-Z0-9_@#$%&*+=\-\.]+:([A-Za-z0-9_@#$%&*+=\-\.]+)(?::[A-Za-z]+)?\}/g; export const Expression = ({ grokCollection, pattern, + patternSlotId, onChange, height = '100px', dataTestSubj, }: { grokCollection: GrokCollection; pattern: string; + /** Must match the preview draft slot for this row so field colours stay stable while typing. */ + patternSlotId?: string | number; onChange?: (pattern: string) => void; height?: CodeEditorProps['height']; dataTestSubj?: string; @@ -30,27 +40,49 @@ export const Expression = ({ return grokCollection.getSuggestionProvider(); }); + const { euiTheme } = useEuiTheme(); + const draftGrokExpression = useMemo(() => { - return new DraftGrokExpression(grokCollection, pattern); + return new DraftGrokExpression(grokCollection, pattern, { patternSlotId }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [grokCollection]); + }, [grokCollection, patternSlotId]); + + const [editorValue, setEditorValue] = useState(pattern); + const pendingLocalChangeRef = useRef(false); - // Sync pattern prop with internal DraftGrokExpression + // Sync external pattern changes without clobbering in-progress local edits. useEffect(() => { - const currentExpression = draftGrokExpression.getExpression(); - if (currentExpression !== pattern) { + if (pendingLocalChangeRef.current) { + if (pattern === draftGrokExpression.getExpression()) { + pendingLocalChangeRef.current = false; + setEditorValue(pattern); + } + return; + } + if (draftGrokExpression.getExpression() !== pattern) { draftGrokExpression.updateExpression(pattern); } + setEditorValue(pattern); }, [pattern, draftGrokExpression]); const grokEditorRef = useRef(null); + const decorationsRef = useRef(null); const { containerRef, setupResizeChecker, destroyResizeChecker } = useResizeChecker(); + // Monaco can't accept inline styles per-decoration; we pass class names via inlineClassName + // and inject the corresponding CSS rules onto the wrapper via Emotion so the classes resolve. + const colourPaletteStyles = useMemo( + () => grokCollection.getColourPaletteStyles(euiTheme), + [euiTheme, grokCollection] + ); + const onGrokEditorMount: CodeEditorProps['editorDidMount'] = ( editor: monaco.editor.IStandaloneCodeEditor ) => { grokEditorRef.current = editor; + decorationsRef.current = editor.createDecorationsCollection(); setupResizeChecker(editor); + updateDecorations(draftGrokExpression, grokCollection, grokEditorRef, decorationsRef); }; const onGrokEditorWillUnmount: CodeEditorProps['editorWillUnmount'] = () => { @@ -58,13 +90,25 @@ export const Expression = ({ }; const onGrokEditorChange: CodeEditorProps['onChange'] = (value) => { + pendingLocalChangeRef.current = true; + setEditorValue(value); draftGrokExpression.updateExpression(value); onChange?.(value); + updateDecorations(draftGrokExpression, grokCollection, grokEditorRef, decorationsRef); }; + // Re-apply decorations when the pattern prop changes externally (e.g. form state rewrites + // the value, or another consumer drives the editor). + useEffect(() => { + updateDecorations(draftGrokExpression, grokCollection, grokEditorRef, decorationsRef); + }, [pattern, draftGrokExpression, grokCollection]); + return (
); }; + +// Scans the editor's current text for `%{SYNTAX:field}` tokens and applies an inline class on +// each so the token background matches the colour assigned to that field by the resolved +// pattern (and by extension the preview-table highlight for the same field). +const updateDecorations = ( + draftGrokExpression: DraftGrokExpression, + grokCollection: GrokCollection, + editorRef: React.MutableRefObject, + decorationsCollectionRef: React.MutableRefObject +) => { + const editor = editorRef.current; + const decorationsCollection = decorationsCollectionRef.current; + if (!editor || !decorationsCollection) return; + + const model = editor.getModel(); + if (!model) return; + + const fields = draftGrokExpression.getFields(); + // Build a field name -> colour lookup from the resolved fields. Multiple capture-group ids + // can share a field name (e.g. same field referenced from two patterns); first one wins. + const fieldColourMap = new Map(); + for (const [, fieldDef] of fields) { + if (!fieldColourMap.has(fieldDef.name)) { + fieldColourMap.set(fieldDef.name, fieldDef.colour); + } + } + + const text = model.getValue(); + const decorations: monaco.editor.IModelDeltaDecoration[] = []; + + GROK_FIELD_PATTERN_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = GROK_FIELD_PATTERN_REGEX.exec(text)) !== null) { + const fieldName = match[1]; + const colour = fieldColourMap.get(fieldName) ?? grokCollection.lookupAssignedColour(fieldName); + if (!colour) continue; + + const startPos = model.getPositionAt(match.index); + const endPos = model.getPositionAt(match.index + match[0].length); + decorations.push({ + range: { + startLineNumber: startPos.lineNumber, + startColumn: startPos.column, + endLineNumber: endPos.lineNumber, + endColumn: endPos.column, + }, + options: { + inlineClassName: colourToClassName(colour), + }, + }); + } + + decorationsCollection.clear(); + decorationsCollection.set(decorations); +}; diff --git a/src/platform/packages/shared/kbn-grok-ui/contexts/grok_expressions_context.tsx b/src/platform/packages/shared/kbn-grok-ui/contexts/grok_expressions_context.tsx index bc01076d84bcb..f3556cd669cc7 100644 --- a/src/platform/packages/shared/kbn-grok-ui/contexts/grok_expressions_context.tsx +++ b/src/platform/packages/shared/kbn-grok-ui/contexts/grok_expressions_context.tsx @@ -63,7 +63,9 @@ export const useGrokExpressions = (patterns: string[]): DraftGrokExpression[] => while (newExpressions.length < patterns.length) { const idx = newExpressions.length; - newExpressions.push(new DraftGrokExpressionClass(grokCollection, patterns[idx])); + newExpressions.push( + new DraftGrokExpressionClass(grokCollection, patterns[idx], { patternSlotId: idx }) + ); } // Update expressions that have changed diff --git a/src/platform/packages/shared/kbn-grok-ui/models/draft_grok_expression.ts b/src/platform/packages/shared/kbn-grok-ui/models/draft_grok_expression.ts index 6595831af5159..682c358e4cadf 100644 --- a/src/platform/packages/shared/kbn-grok-ui/models/draft_grok_expression.ts +++ b/src/platform/packages/shared/kbn-grok-ui/models/draft_grok_expression.ts @@ -9,21 +9,36 @@ import type { Subscription } from 'rxjs'; import { BehaviorSubject } from 'rxjs'; -import type { GrokCollection } from './grok_collection_and_pattern'; +import type { GrokCollection, GrokFieldUsageSource } from './grok_collection_and_pattern'; import { GrokPattern } from './grok_collection_and_pattern'; -export class DraftGrokExpression { +export interface DraftGrokExpressionOptions { + patternSlotId?: string | number; +} + +export class DraftGrokExpression implements GrokFieldUsageSource { private expression: string = ''; private grokPattern: GrokPattern; private expression$: BehaviorSubject; private customPatternsSubscription: Subscription; + private readonly unregisterFieldUsage: () => void; + private previousFieldNames: Set; + private readonly patternSlotId: string | number | undefined; - constructor(collection: GrokCollection, initialExpression?: string) { + constructor( + collection: GrokCollection, + initialExpression?: string, + options?: DraftGrokExpressionOptions + ) { const expression = initialExpression ?? ''; this.expression = expression; + this.patternSlotId = options?.patternSlotId; + this.previousFieldNames = collection.parseFieldNames(expression); + this.unregisterFieldUsage = collection.registerFieldUsageSource(this); this.grokPattern = new GrokPattern(expression || '', 'DRAFT_GROK_EXPRESSION', collection); this.grokPattern.resolvePattern(); this.expression$ = new BehaviorSubject(expression); + collection.flushFieldUsage(); this.customPatternsSubscription = collection.customPatternsChanged$.subscribe(() => { this.grokPattern.resolvePattern(true); this.expression$.next(this.expression); @@ -31,9 +46,14 @@ export class DraftGrokExpression { } public updateExpression = (expression: string) => { + const collection = this.grokPattern.getParentCollection(); + const nextFieldNames = collection.parseFieldNames(expression); + collection.reconcileFieldUsage(this, this.previousFieldNames, nextFieldNames); this.expression = expression; + this.previousFieldNames = nextFieldNames; this.grokPattern.updatePattern(this.expression); this.grokPattern.resolvePattern(true); + collection.flushFieldUsage(); this.expression$.next(this.expression); }; @@ -57,11 +77,21 @@ export class DraftGrokExpression { return this.expression; }; + public getFieldNames = (): ReadonlySet => { + return this.previousFieldNames; + }; + + public getPatternSlotId = () => { + return this.patternSlotId; + }; + public getExpression$ = () => { return this.expression$; }; public destroy = () => { this.customPatternsSubscription.unsubscribe(); + this.unregisterFieldUsage(); + this.grokPattern.getParentCollection().flushFieldUsage(); }; } diff --git a/src/platform/packages/shared/kbn-grok-ui/models/grok_collection_and_pattern.ts b/src/platform/packages/shared/kbn-grok-ui/models/grok_collection_and_pattern.ts index 432faf07ad76d..b55001ea257fd 100644 --- a/src/platform/packages/shared/kbn-grok-ui/models/grok_collection_and_pattern.ts +++ b/src/platform/packages/shared/kbn-grok-ui/models/grok_collection_and_pattern.ts @@ -27,6 +27,18 @@ import { SupportedTypeConversion } from './types'; const SUBPATTERNS_REGEX = /%\{[A-Z0-9_@#$%&*+=\-\.]+(?::[A-Za-z0-9_@#$%&*+=\-\.]+)?(?::[A-Za-z]+)?\}/g; +// Matches %{SYNTAX:SEMANTIC} and %{SYNTAX:SEMANTIC:TYPE} tokens for field-name extraction. +const GROK_FIELD_NAMES_REGEX = + /%\{[A-Z0-9_@#$%&*+=\-\.]+:([A-Za-z0-9_@#$%&*+=\-\.]+)(?::[A-Za-z]+)?\}/g; + +// Matches manual semantic names in (?...) capture groups. +const MANUAL_FIELD_NAMES_REGEX = /\(\?<([A-Za-z0-9_@#$%&*+=\-\.]+)(?::|>)/g; + +export interface GrokFieldUsageSource { + getFieldNames(): ReadonlySet; + getPatternSlotId?(): string | number | undefined; +} + // Matches "manual" semantic names in the expression, these are user defined capture groups, e.g. (?the pattern here) const NESTED_FIELD_NAMES_REGEX = /(\(\?<([A-Za-z0-9_@#$%&*+=\-\.]+)(?::([A-Za-z0-9_@#$%&*+=\-\.]+))?(?::([A-Za-z]+))?>)|\(\?:|\(\?>|\(\?!|\(\?(); // Combination of core and custom patterns. private patternKeys: string[] = []; + // Stable map of field name -> palette colour. Ensures the same field always gets the same + // colour across all patterns within this collection, instead of rotating positionally per + // pattern. + private fieldColourMap = new Map(); + private readonly fieldUsageSources = new Set(); private colourIndex = 0; // NOTE: Model as async for now with future intent to use the /_ingest/processor/grok endpoint @@ -158,11 +175,113 @@ export class GrokCollection { return provider; }; - public getColour = () => { - // Loop back to 0 once at the end of the rotations - this.colourIndex = - this.colourIndex + 1 < EUI_COLOR_PALETTE_VALUES.length ? this.colourIndex + 1 : 0; - return EUI_COLOR_PALETTE_VALUES[this.colourIndex]; + public registerFieldUsageSource = (source: GrokFieldUsageSource): (() => void) => { + this.fieldUsageSources.add(source); + return () => { + this.fieldUsageSources.delete(source); + }; + }; + + public parseFieldNames = (expression: string): Set => { + const names = new Set(); + + GROK_FIELD_NAMES_REGEX.lastIndex = 0; + let grokMatch: RegExpExecArray | null; + while ((grokMatch = GROK_FIELD_NAMES_REGEX.exec(expression)) !== null) { + names.add(grokMatch[1]); + } + + MANUAL_FIELD_NAMES_REGEX.lastIndex = 0; + let manualMatch: RegExpExecArray | null; + while ((manualMatch = MANUAL_FIELD_NAMES_REGEX.exec(expression)) !== null) { + names.add(manualMatch[1]); + } + + return names; + }; + + public reconcileFieldUsage = ( + source: GrokFieldUsageSource, + previousFieldNames: ReadonlySet, + nextFieldNames: ReadonlySet + ) => { + if (previousFieldNames.size === 0 && nextFieldNames.size === 0) return; + + const released: string[] = []; + for (const name of previousFieldNames) { + if (!nextFieldNames.has(name)) released.push(name); + } + if (released.length !== 1) return; + + const acquired: string[] = []; + for (const name of nextFieldNames) { + if (!previousFieldNames.has(name)) acquired.push(name); + } + if (acquired.length !== 1) return; + + const [releasedName] = released; + const [acquiredName] = acquired; + + if (this.isFieldNameUsedByOtherSources(releasedName, source)) return; + + // The new name is already registered (e.g. mid-rename now matches a sibling row). + // Adopt that colour and free the old name's slot for future rotation. + if (this.fieldColourMap.has(acquiredName)) { + this.fieldColourMap.delete(releasedName); + return; + } + + const colour = this.fieldColourMap.get(releasedName); + if (colour === undefined) return; + this.fieldColourMap.delete(releasedName); + this.fieldColourMap.set(acquiredName, colour); + }; + + /** + * Drops colour-map entries for field names that are no longer referenced by any registered + * draft. + */ + public flushFieldUsage = () => { + const activeFieldNames = new Set(); + for (const source of this.fieldUsageSources) { + for (const name of source.getFieldNames()) activeFieldNames.add(name); + } + for (const fieldName of this.fieldColourMap.keys()) { + if (!activeFieldNames.has(fieldName)) this.fieldColourMap.delete(fieldName); + } + }; + + /** + * Returns the colour assigned to a field name, allocating the next palette slot if the + * name has never been seen. + */ + public getColour = (fieldName?: string): string => { + if (fieldName !== undefined) { + const existing = this.fieldColourMap.get(fieldName); + if (existing !== undefined) return existing; + } + const colour = EUI_COLOR_PALETTE_VALUES[this.colourIndex % EUI_COLOR_PALETTE_VALUES.length]; + this.colourIndex++; + if (fieldName !== undefined) this.fieldColourMap.set(fieldName, colour); + return colour; + }; + + /** Read-only colour lookup for UI decoration without advancing the palette. */ + public lookupAssignedColour = (fieldName: string): string | undefined => { + return this.fieldColourMap.get(fieldName); + }; + + private isFieldNameUsedByOtherSources = ( + fieldName: string, + exclude: GrokFieldUsageSource + ): boolean => { + const excludeSlotId = exclude.getPatternSlotId?.(); + for (const source of this.fieldUsageSources) { + if (source === exclude) continue; + if (excludeSlotId !== undefined && source.getPatternSlotId?.() === excludeSlotId) continue; + if (source.getFieldNames().has(fieldName)) return true; + } + return false; }; // Only relevant for Monaco users. @@ -181,11 +300,8 @@ export class GrokCollection { } return styles; }; - - public resetColourIndex = () => { - this.colourIndex = 0; - }; } + export class GrokPattern { // The raw pattern, this might be a direct Oniguruma expression, or an expression that contains Grok subpatterns. // E.g. INT (?:[+-]?(?:[0-9]+)) or MAC (?:%{CISCOMAC}|%{WINDOWSMAC}|%{COMMONMAC}) @@ -206,6 +322,10 @@ export class GrokPattern { this.parentCollection = collection; } + public getParentCollection = () => { + return this.parentCollection; + }; + public isResolved() { return this.resolvedPattern !== null; } @@ -215,7 +335,6 @@ export class GrokPattern { return this.resolvedPattern; } - this.parentCollection.resetColourIndex(); this.fields.clear(); this.resolveSubPatterns(); this.resolveFieldNames(); @@ -269,7 +388,7 @@ export class GrokPattern { fieldType && SUPPORTED_TYPE_CONVERSIONS.includes(fieldType as SupportedTypeConversion) ? (fieldType as SupportedTypeConversion) : null, - colour: this.parentCollection.getColour(), + colour: this.parentCollection.getColour(fieldName), pattern: matched, }; this.fields.set(generatedId, fieldEntry); @@ -338,14 +457,15 @@ export class GrokPattern { // This check is so we don't reprocess the field name replacements from resolveSubPatterns if (!matched[2].includes('_____GENERATED_CAPTURE_GROUP_____')) { + const resolvedFieldName = matched[3] ?? matched[2]; this.fields.set(generatedId, { - name: matched[3] ?? matched[2], + name: resolvedFieldName, type: matched[4] && SUPPORTED_TYPE_CONVERSIONS.includes(matched[4] as SupportedTypeConversion) ? (matched[4] as SupportedTypeConversion) : null, - colour: this.parentCollection.getColour(), + colour: this.parentCollection.getColour(resolvedFieldName), pattern: `${CUSTOM_NAMED_CAPTURE_PATTERN_PREFIX} ${escape( String.raw`${matched[1]}` )}`, diff --git a/src/platform/packages/shared/kbn-grok-ui/models/grok_models.test.ts b/src/platform/packages/shared/kbn-grok-ui/models/grok_models.test.ts index a243b2c382828..2d5057b67e993 100644 --- a/src/platform/packages/shared/kbn-grok-ui/models/grok_models.test.ts +++ b/src/platform/packages/shared/kbn-grok-ui/models/grok_models.test.ts @@ -177,4 +177,224 @@ describe('Grok models', () => { ]); }); }); + + describe('Field colour assignment', () => { + let collection: GrokCollection; + + const PALETTE = [ + 'Primary', + 'Accent', + 'AccentSecondary', + 'Neutral', + 'Success', + 'Warning', + 'Risk', + 'Danger', + ] as const; + + const colourFor = (expression: DraftGrokExpression, fieldName: string): string | undefined => { + for (const fieldDef of expression.getFields().values()) { + if (fieldDef.name === fieldName) return fieldDef.colour; + } + return undefined; + }; + + beforeEach(async () => { + // Use a fresh collection so prior tests don't leak into the field colour map. + collection = new GrokCollection(); + await collection.setup(); + }); + + it('returns the same colour for the same field name across resolves of the same pattern', () => { + const expression = new DraftGrokExpression(collection, '%{NUMBER:foo}'); + const firstColour = colourFor(expression, 'foo'); + expect(firstColour).toBeDefined(); + + // Force the pattern to resolve again (simulates an external update of the same value). + expression.updateExpression('%{NUMBER:foo}'); + expect(colourFor(expression, 'foo')).toBe(firstColour); + }); + + it('shares the same colour for the same field name across multiple patterns', () => { + // Two patterns referencing the same field share one colour — preventing collisions of + // identical fields across rows in the grok processor editor. + const a = new DraftGrokExpression(collection, '%{NUMBER:status} %{WORD:method}'); + const b = new DraftGrokExpression(collection, '%{NUMBER:status} %{GREEDYDATA:message}'); + expect(colourFor(a, 'status')).toBe(colourFor(b, 'status')); + }); + + it('adopts an existing field colour when typing reaches a name already used on another row', () => { + const row1 = new DraftGrokExpression( + collection, + '%{WORD:custom.timestamp} %{WORD:host.hostname}', + { + patternSlotId: 0, + } + ); + const row2 = new DraftGrokExpression( + collection, + '%{WORD:custom.timestamp} %{WORD:process.name}', + { + patternSlotId: 1, + } + ); + const sharedColour = colourFor(row1, 'custom.timestamp'); + expect(sharedColour).toBeDefined(); + expect(colourFor(row2, 'custom.timestamp')).toBe(sharedColour); + + const row3 = new DraftGrokExpression( + collection, + '%{GREEDYDATA:message} %{WORD:host.hostname}', + { + patternSlotId: 2, + } + ); + const messageColour = colourFor(row3, 'message'); + expect(messageColour).toBeDefined(); + + // Row 3 renames its first field to match rows 1 & 2, letter by letter (slot anchor was messageColour). + row3.updateExpression('%{WORD:custom.timestam} %{WORD:host.hostname}'); + row3.updateExpression('%{WORD:custom.timestamp} %{WORD:host.hostname}'); + + expect(colourFor(row3, 'custom.timestamp')).toBe(sharedColour); + expect(colourFor(row3, 'custom.timestamp')).not.toBe(messageColour); + expect(colourFor(row1, 'custom.timestamp')).toBe(sharedColour); + }); + + it('assigns distinct colours to distinct field names within the palette size', () => { + const a = new DraftGrokExpression(collection, '%{NUMBER:a}'); + const b = new DraftGrokExpression(collection, '%{NUMBER:b}'); + expect(colourFor(a, 'a')).not.toBe(colourFor(b, 'b')); + }); + + it('keeps the colour stable while typing the same field (regression: per-keystroke churn)', () => { + const expression = new DraftGrokExpression(collection, '%{NUMBER:f}'); + const stableColour = colourFor(expression, 'f'); + expect(stableColour).toBeDefined(); + + expression.updateExpression('%{NUMBER:fo}'); + expect(colourFor(expression, 'fo')).toBe(stableColour); + + expression.updateExpression('%{NUMBER:foo}'); + expect(colourFor(expression, 'foo')).toBe(stableColour); + + expression.updateExpression('%{NUMBER:fooz}'); + expect(colourFor(expression, 'fooz')).toBe(stableColour); + }); + + it('reuses a freed palette slot when one field is renamed while another is unchanged', () => { + const expression = new DraftGrokExpression(collection, '%{NUMBER:status} %{WORD:method}'); + const statusColour = colourFor(expression, 'status'); + const methodColour = colourFor(expression, 'method'); + expect(statusColour).not.toBe(methodColour); + + expression.updateExpression('%{NUMBER:status} %{WORD:m}'); + expect(colourFor(expression, 'status')).toBe(statusColour); + expect(colourFor(expression, 'm')).toBe(methodColour); + + expression.updateExpression('%{NUMBER:status} %{WORD:meth}'); + expect(colourFor(expression, 'status')).toBe(statusColour); + expect(colourFor(expression, 'meth')).toBe(methodColour); + }); + + it('frees a colour slot when a draft is destroyed and the next fresh field rotates to a new colour', () => { + const a = new DraftGrokExpression(collection, '%{NUMBER:foo}'); + const fooColour = colourFor(a, 'foo'); + expect(fooColour).toBeDefined(); + expect(PALETTE).toContain(fooColour as (typeof PALETTE)[number]); + + a.destroy(); + + const b = new DraftGrokExpression(collection, '%{NUMBER:bar}'); + const barColour = colourFor(b, 'bar'); + expect(barColour).toBeDefined(); + expect(PALETTE).toContain(barColour as (typeof PALETTE)[number]); + expect(barColour).not.toBe(fooColour); + }); + + it('rotates to a new colour when the field is deleted then retyped under a different name (regression: delete-then-retype)', () => { + const editor = new DraftGrokExpression(collection, '%{IP:foo}', { patternSlotId: 0 }); + const preview = new DraftGrokExpression(collection, '%{IP:foo}', { patternSlotId: 0 }); + const fooColour = colourFor(editor, 'foo'); + expect(fooColour).toBeDefined(); + expect(colourFor(preview, 'foo')).toBe(fooColour); + + // Step 1: clear the field name. `%{IP:}` does not register a semantic field, so the + // post-update flush drops `foo` from the colour map once neither draft references it. + editor.updateExpression('%{IP:}'); + preview.updateExpression('%{IP:}'); + + // Step 2: type a brand-new name in a follow-up edit. There is no in-flight release + // for reconcile to transfer from, so getColour rotates to the next palette slot. + editor.updateExpression('%{IP:bar}'); + preview.updateExpression('%{IP:bar}'); + + const barColour = colourFor(editor, 'bar'); + expect(barColour).toBeDefined(); + expect(PALETTE).toContain(barColour as (typeof PALETTE)[number]); + expect(barColour).not.toBe(fooColour); + expect(colourFor(preview, 'bar')).toBe(barColour); + }); + + it('assigns distinct colours to each field when a row replaces all its fields in one edit (regression: multi-field rename)', () => { + const row = new DraftGrokExpression(collection, '%{NUMBER:a} %{WORD:b}', { + patternSlotId: 0, + }); + const aColour = colourFor(row, 'a'); + const bColour = colourFor(row, 'b'); + expect(aColour).toBeDefined(); + expect(bColour).toBeDefined(); + expect(aColour).not.toBe(bColour); + + row.updateExpression('%{NUMBER:c} %{WORD:d}'); + + const cColour = colourFor(row, 'c'); + const dColour = colourFor(row, 'd'); + expect(cColour).toBeDefined(); + expect(dColour).toBeDefined(); + expect(PALETTE).toContain(cColour as (typeof PALETTE)[number]); + expect(PALETTE).toContain(dColour as (typeof PALETTE)[number]); + // The two new fields must land on different palette slots. + expect(cColour).not.toBe(dColour); + }); + + it('always assigns palette colours and wraps around once the palette is exhausted', () => { + const drafts = Array.from( + { length: 10 }, + (_, i) => new DraftGrokExpression(collection, `%{NUMBER:f${i}}`) + ); + const colours = drafts.map((draft, i) => colourFor(draft, `f${i}`)); + + colours.forEach((colour) => { + expect(colour).toBeDefined(); + expect(PALETTE).toContain(colour as (typeof PALETTE)[number]); + }); + + // The first 8 unique fields advance the rotation cursor through 8 consecutive slots, so + // they must all be different colours. + expect(new Set(colours.slice(0, 8)).size).toBe(8); + // The 9th and 10th wrap back through the rotation, so they reuse the same colours as + // the 1st and 2nd respectively. + expect(colours[8]).toBe(colours[0]); + expect(colours[9]).toBe(colours[1]); + }); + + it('shares colours for manual (?...) capture groups across patterns', () => { + const a = new DraftGrokExpression(collection, '(?[0-9A-F]{10,11})'); + const b = new DraftGrokExpression(collection, '%{WORD:level} (?[0-9A-F]{10,11})'); + expect(colourFor(a, 'queueId')).toBe(colourFor(b, 'queueId')); + }); + + it('keeps colours stable while typing a manual (?...) capture group', () => { + const expression = new DraftGrokExpression(collection, '(?[0-9A-F]+)'); + const stableColour = colourFor(expression, 'q'); + expect(stableColour).toBeDefined(); + + expression.updateExpression('(?[0-9A-F]+)'); + expect(colourFor(expression, 'qu')).toBe(stableColour); + + expression.updateExpression('(?[0-9A-F]+)'); + expect(colourFor(expression, 'queueId')).toBe(stableColour); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/stream_management/data_management/stream_detail_enrichment/steps/blocks/action/grok/grok_patterns_editor.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/stream_management/data_management/stream_detail_enrichment/steps/blocks/action/grok/grok_patterns_editor.tsx index d4cba1860388b..fd87a0acee3ef 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/stream_management/data_management/stream_detail_enrichment/steps/blocks/action/grok/grok_patterns_editor.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/stream_management/data_management/stream_detail_enrichment/steps/blocks/action/grok/grok_patterns_editor.tsx @@ -186,6 +186,7 @@ const DraggablePatternInput = ({