Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,15 @@
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,
Expand All @@ -30,6 +37,8 @@ export const Expression = ({
return grokCollection.getSuggestionProvider();
});

const { euiTheme } = useEuiTheme();

const draftGrokExpression = useMemo(() => {
return new DraftGrokExpression(grokCollection, pattern);
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand All @@ -44,13 +53,23 @@ export const Expression = ({
}, [pattern, draftGrokExpression]);

const grokEditorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const decorationsRef = useRef<monaco.editor.IEditorDecorationsCollection | null>(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, grokEditorRef, decorationsRef);
};

const onGrokEditorWillUnmount: CodeEditorProps['editorWillUnmount'] = () => {
Expand All @@ -60,11 +79,21 @@ export const Expression = ({
const onGrokEditorChange: CodeEditorProps['onChange'] = (value) => {
draftGrokExpression.updateExpression(value);
onChange?.(value);
updateDecorations(draftGrokExpression, 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, grokEditorRef, decorationsRef);
}, [pattern, draftGrokExpression]);

return (
<div
ref={containerRef}
css={css`
${colourPaletteStyles}
`}
style={{
width: '100%',
height,
Expand All @@ -86,3 +115,57 @@ export const Expression = ({
</div>
);
};

// 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,
editorRef: React.MutableRefObject<monaco.editor.IStandaloneCodeEditor | null>,
decorationsCollectionRef: React.MutableRefObject<monaco.editor.IEditorDecorationsCollection | null>
) => {
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<string, string>();
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);
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);
};
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ export class GrokCollection {
// Combination of core and custom patterns.
private patternKeys: string[] = [];
private colourIndex = 0;
// 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<string, string>();

// NOTE: Model as async for now with future intent to use the /_ingest/processor/grok endpoint
public async setup() {
Expand Down Expand Up @@ -158,11 +162,18 @@ 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 getColour = (fieldName?: string) => {
// If a field name is provided and already has an assigned colour, return it so the same
// field gets the same colour across all patterns in this collection.
if (fieldName && this.fieldColourMap.has(fieldName)) {
return this.fieldColourMap.get(fieldName)!;
}
const colour = EUI_COLOR_PALETTE_VALUES[this.colourIndex % EUI_COLOR_PALETTE_VALUES.length];
this.colourIndex++;
if (fieldName) {
this.fieldColourMap.set(fieldName, colour);
}
return colour;
};

// Only relevant for Monaco users.
Expand All @@ -184,6 +195,7 @@ export class GrokCollection {

public resetColourIndex = () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like we don't need resetColourIndex anymore?

this.colourIndex = 0;
this.fieldColourMap.clear();
};
}
export class GrokPattern {
Expand Down Expand Up @@ -215,7 +227,6 @@ export class GrokPattern {
return this.resolvedPattern;
}

this.parentCollection.resetColourIndex();
this.fields.clear();
this.resolveSubPatterns();
this.resolveFieldNames();
Expand Down Expand Up @@ -269,7 +280,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);
Expand Down Expand Up @@ -338,14 +349,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]}`
)}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,4 +177,91 @@ describe('Grok models', () => {
]);
});
});

describe('Field colour assignment', () => {
let collection: GrokCollection;

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', () => {
// This is the original bug: previously, the first field of every pattern reused the same
// palette slot because the colour index reset on each resolve. Now the colour is keyed by
// field name, so two patterns referencing the same field share one colour.
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('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('wraps around the palette after exhausting all 8 colours and still returns palette colours', () => {
const fieldNames = ['f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10'];
const colours = fieldNames.map((name) => collection.getColour(name));

const palette = [
'Primary',
'Accent',
'AccentSecondary',
'Neutral',
'Success',
'Warning',
'Risk',
'Danger',
];
colours.forEach((colour) => {
expect(palette).toContain(colour);
});

// The 9th and 10th unique fields wrap back to the start of the palette.
expect(colours[8]).toBe(colours[0]);
expect(colours[9]).toBe(colours[1]);
});

it('resetColourIndex() clears the field colour map', () => {
const initialColour = collection.getColour('foo');
collection.resetColourIndex();
// After reset, the next assignment can reclaim the very first palette slot. We only
// assert the map was cleared (a new lookup is independent of the previous one) — the
// exact colour returned can match `initialColour` if `foo` happens to land on the same
// slot, so we instead verify behaviour via two new fields after reset.
const afterResetA = collection.getColour('a');
const afterResetB = collection.getColour('b');
expect(afterResetA).not.toBe(afterResetB);
// The previous colour for `foo` may or may not match the colour now assigned to `a`,
// but it must be a valid palette entry.
expect(typeof initialColour).toBe('string');
expect(typeof afterResetA).toBe('string');
});

it('shares colours for manual (?<field>...) capture groups across patterns', () => {
const a = new DraftGrokExpression(collection, '(?<queueId>[0-9A-F]{10,11})');
const b = new DraftGrokExpression(collection, '%{WORD:level} (?<queueId>[0-9A-F]{10,11})');
expect(colourFor(a, 'queueId')).toBe(colourFor(b, 'queueId'));
});
});
});
Loading