Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: derived fields settings implements #231

Merged
merged 2 commits into from
Feb 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## tip

## v0.15.0

* FEAT: add configuration screen for derived fields

## v0.14.3

* BUGFIX: fix image links in public readme.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"@emotion/eslint-plugin": "^11.11.0",
"@grafana/data": "11.1.0",
"@grafana/lezer-logql": "^0.2.5",
"@grafana/plugin-ui": "^0.10.1",
"@grafana/runtime": "11.1.0",
"@grafana/schema": "^9.5.20",
"@grafana/ui": "11.1.0",
Expand Down
6 changes: 6 additions & 0 deletions src/configuration/ConfigEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { AlertingSettings } from './AlertingSettings';
import { HelpfulLinks } from "./HelpfulLinks";
import { LimitsSettings } from "./LimitSettings";
import { QuerySettings } from './QuerySettings';
import { DerivedFields } from "./DerivedFields";

export type Props = DataSourcePluginOptionsEditorProps<Options>;

Expand All @@ -23,6 +24,7 @@ const makeJsonUpdater = <T extends any>(field: keyof Options) =>
})

const setMaxLines = makeJsonUpdater('maxLines');
const setDerivedFields = makeJsonUpdater('derivedFields');

const ConfigEditor = (props: Props) => {
const { options, onOptionsChange } = props;
Expand All @@ -41,6 +43,10 @@ const ConfigEditor = (props: Props) => {
maxLines={options.jsonData.maxLines || ''}
onMaxLinedChange={(value) => onOptionsChange(setMaxLines(options, value))}
/>
<DerivedFields
fields={options.jsonData.derivedFields}
onChange={(value) => onOptionsChange(setDerivedFields(options, value))}
/>
<LimitsSettings {...props}/>
</>
);
Expand Down
110 changes: 110 additions & 0 deletions src/configuration/DebugSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import React, { ReactNode, useState } from 'react';

import { getTemplateSrv } from '@grafana/runtime';
import { InlineField, TextArea } from '@grafana/ui';

import { DerivedFieldConfig } from '../types';

type Props = {
derivedFields?: DerivedFieldConfig[];
className?: string;
};
export const DebugSection = (props: Props) => {
const { derivedFields, className } = props;
const [debugText, setDebugText] = useState('');

let debugFields: DebugField[] = [];
if (debugText && derivedFields) {
debugFields = makeDebugFields(derivedFields, debugText);
}

return (
<div className={className}>
<InlineField label="Debug log message" labelWidth={24} grow>
<TextArea
type="text"
aria-label="Loki query"
placeholder="Paste an example log line here to test the regular expressions of your derived fields"
value={debugText}
onChange={(event) => setDebugText(event.currentTarget.value)}
/>
</InlineField>
{!!debugFields.length && <DebugFields fields={debugFields} />}
</div>
);
};

type DebugFieldItemProps = {
fields: DebugField[];
};
const DebugFields = ({ fields }: DebugFieldItemProps) => {
return (
<table className={'filter-table'}>
<thead>
<tr>
<th>Name</th>
<th>Value</th>
<th>Url</th>
</tr>
</thead>
<tbody>
{fields.map((field) => {
let value: ReactNode = field.value;
if (field.error && field.error instanceof Error) {
value = field.error.message;
} else if (field.href) {
value = <a href={field.href}>{value}</a>;
}
return (
<tr key={`${field.name}=${field.value}`}>
<td>{field.name}</td>
<td>{value}</td>
<td>{field.href ? <a href={field.href}>{field.href}</a> : ''}</td>
</tr>
);
})}
</tbody>
</table>
);
};

type DebugField = {
name: string;
error?: unknown;
value?: string;
href?: string;
};

function makeDebugFields(derivedFields: DerivedFieldConfig[], debugText: string): DebugField[] {
return derivedFields
.filter((field) => field.name && field.matcherRegex)
.map((field) => {
try {
const testMatch = debugText.match(field.matcherRegex);
let href;
const value = testMatch && testMatch[1];

if (value) {
href = getTemplateSrv().replace(field.url, {
__value: {
value: {
raw: value,
},
text: 'Raw value',
},
});
}
const debugFiled: DebugField = {
name: field.name,
value: value || '<no match>',
href,
};
return debugFiled;
} catch (error) {
return {
name: field.name,
error,
};
}
});
}
215 changes: 215 additions & 0 deletions src/configuration/DerivedField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { css } from '@emotion/css';
import { ChangeEvent, useEffect, useState } from 'react';
import * as React from 'react';
import { usePrevious } from 'react-use';

import { GrafanaTheme2, DataSourceInstanceSettings, VariableSuggestion } from '@grafana/data';
import { DataSourcePicker } from '@grafana/runtime';
import { Button, DataLinkInput, Field, Icon, Input, Label, Tooltip, useStyles2, Select, Switch } from '@grafana/ui';

import { DerivedFieldConfig } from '../types';

type MatcherType = 'label' | 'regex';

const getStyles = (theme: GrafanaTheme2) => ({
row: css({
display: 'flex',
alignItems: 'baseline',
}),
nameField: css({
flex: 2,
marginRight: theme.spacing(0.5),
}),
regexField: css({
flex: 3,
marginRight: theme.spacing(0.5),
}),
urlField: css({
flex: 1,
marginRight: theme.spacing(0.5),
}),
urlDisplayLabelField: css({
flex: 1,
}),
internalLink: css({
marginRight: theme.spacing(1),
}),
openNewTab: css({
marginRight: theme.spacing(1),
}),
dataSource: css({}),
nameMatcherField: css({
width: theme.spacing(20),
marginRight: theme.spacing(0.5),
}),
});

type Props = {
value: DerivedFieldConfig;
onChange: (value: DerivedFieldConfig) => void;
onDelete: () => void;
suggestions: VariableSuggestion[];
className?: string;
validateName: (name: string) => boolean;
};
export const DerivedField = (props: Props) => {
const { value, onChange, onDelete, suggestions, className, validateName } = props;
const styles = useStyles2(getStyles);
const [showInternalLink, setShowInternalLink] = useState(!!value.datasourceUid);
const previousUid = usePrevious(value.datasourceUid);
const [fieldType, setFieldType] = useState<MatcherType>(value.matcherType ?? 'regex');

// Force internal link visibility change if uid changed outside of this component.
useEffect(() => {
if (!previousUid && value.datasourceUid && !showInternalLink) {
setShowInternalLink(true);
}
if (previousUid && !value.datasourceUid && showInternalLink) {
setShowInternalLink(false);
}
}, [previousUid, value.datasourceUid, showInternalLink]);

const handleChange = (field: keyof typeof value) => (event: React.ChangeEvent<HTMLInputElement>) => {
onChange({
...value,
[field]: event.currentTarget.value,
});
};

const invalidName = !validateName(value.name);

return (
<div className={className} data-testid="derived-field">
<div className="gf-form">
<Field className={styles.nameField} label="Name" invalid={invalidName} error="The name is already in use">
<Input value={value.name} onChange={handleChange('name')} placeholder="Field name" invalid={invalidName} />
</Field>
<Field
className={styles.nameMatcherField}
label={
<TooltipLabel
label="Type"
content="Derived fields can be created from labels or by applying a regular expression to the log message."
/>
}
>
<Select
options={[
{ label: 'Regex in log line', value: 'regex' },
{ label: 'Label', value: 'label' },
]}
value={fieldType}
onChange={(type) => {
// make sure this is a valid MatcherType
if (type.value === 'label' || type.value === 'regex') {
setFieldType(type.value);
onChange({
...value,
matcherType: type.value,
});
}
}}
/>
</Field>
<Field
className={styles.regexField}
label={
<>
{fieldType === 'regex' && (
<TooltipLabel
label="Regex"
content="Use to parse and capture some part of the log message. You can use the captured groups in the template."
/>
)}

{fieldType === 'label' && <TooltipLabel label="Label" content="Use to derive the field from a label." />}
</>
}
>
<Input value={value.matcherRegex} onChange={handleChange('matcherRegex')} />
</Field>
<Field label="">
<Button
variant="destructive"
title="Remove field"
icon="times"
onClick={(event) => {
event.preventDefault();
onDelete();
}}
/>
</Field>
</div>

<div className="gf-form">
<Field label={showInternalLink ? 'Query' : 'URL'} className={styles.urlField}>
<DataLinkInput
placeholder={showInternalLink ? '${__value.raw}' : 'http://example.com/${__value.raw}'}
value={value.url || ''}
onChange={(newValue) =>
onChange({
...value,
url: newValue,
})
}
suggestions={suggestions}
/>
</Field>
<Field
className={styles.urlDisplayLabelField}
label={
<TooltipLabel
label="URL Label"
content="Use to override the button label when this derived field is found in a log."
/>
}
>
<Input value={value.urlDisplayLabel} onChange={handleChange('urlDisplayLabel')} />
</Field>
</div>

<div className="gf-form">
<Field label="Internal link" className={styles.internalLink}>
<Switch
value={showInternalLink}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const { checked } = e.currentTarget;
if (!checked) {
onChange({
...value,
datasourceUid: undefined,
});
}
setShowInternalLink(checked);
}}
/>
</Field>

{showInternalLink && (
<Field label="" className={styles.dataSource}>
<DataSourcePicker
tracing={true}
onChange={(ds: DataSourceInstanceSettings) =>
onChange({
...value,
datasourceUid: ds.uid,
})
}
current={value.datasourceUid}
noDefault
/>
</Field>
)}
</div>
</div>
);
};

const TooltipLabel = ({ content, label }: { content: string; label: string }) => (
<Label>
{label}
<Tooltip placement="top" content={content} theme="info">
<Icon tabIndex={0} name="info-circle" size="sm" style={{ marginLeft: '10px' }} />
</Tooltip>
</Label>
);
Loading
Loading