Skip to content

Commit 2fda493

Browse files
authored
feat: derived fields settings implements (#231)
* feat: derived fields settings implements Signed-off-by: pallam <[email protected]> * typo: changelog update Signed-off-by: pallam <[email protected]> --------- Signed-off-by: pallam <[email protected]>
1 parent 362c168 commit 2fda493

File tree

9 files changed

+723
-14
lines changed

9 files changed

+723
-14
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## tip
44

5+
## v0.15.0
6+
7+
* FEAT: add configuration screen for derived fields
8+
59
## v0.14.3
610

711
* BUGFIX: fix image links in public readme.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"@emotion/eslint-plugin": "^11.11.0",
6565
"@grafana/data": "11.1.0",
6666
"@grafana/lezer-logql": "^0.2.5",
67+
"@grafana/plugin-ui": "^0.10.1",
6768
"@grafana/runtime": "11.1.0",
6869
"@grafana/schema": "^9.5.20",
6970
"@grafana/ui": "11.1.0",

src/configuration/ConfigEditor.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { AlertingSettings } from './AlertingSettings';
1010
import { HelpfulLinks } from "./HelpfulLinks";
1111
import { LimitsSettings } from "./LimitSettings";
1212
import { QuerySettings } from './QuerySettings';
13+
import { DerivedFields } from "./DerivedFields";
1314

1415
export type Props = DataSourcePluginOptionsEditorProps<Options>;
1516

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

2526
const setMaxLines = makeJsonUpdater('maxLines');
27+
const setDerivedFields = makeJsonUpdater('derivedFields');
2628

2729
const ConfigEditor = (props: Props) => {
2830
const { options, onOptionsChange } = props;
@@ -41,6 +43,10 @@ const ConfigEditor = (props: Props) => {
4143
maxLines={options.jsonData.maxLines || ''}
4244
onMaxLinedChange={(value) => onOptionsChange(setMaxLines(options, value))}
4345
/>
46+
<DerivedFields
47+
fields={options.jsonData.derivedFields}
48+
onChange={(value) => onOptionsChange(setDerivedFields(options, value))}
49+
/>
4450
<LimitsSettings {...props}/>
4551
</>
4652
);

src/configuration/DebugSection.tsx

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import React, { ReactNode, useState } from 'react';
2+
3+
import { getTemplateSrv } from '@grafana/runtime';
4+
import { InlineField, TextArea } from '@grafana/ui';
5+
6+
import { DerivedFieldConfig } from '../types';
7+
8+
type Props = {
9+
derivedFields?: DerivedFieldConfig[];
10+
className?: string;
11+
};
12+
export const DebugSection = (props: Props) => {
13+
const { derivedFields, className } = props;
14+
const [debugText, setDebugText] = useState('');
15+
16+
let debugFields: DebugField[] = [];
17+
if (debugText && derivedFields) {
18+
debugFields = makeDebugFields(derivedFields, debugText);
19+
}
20+
21+
return (
22+
<div className={className}>
23+
<InlineField label="Debug log message" labelWidth={24} grow>
24+
<TextArea
25+
type="text"
26+
aria-label="Loki query"
27+
placeholder="Paste an example log line here to test the regular expressions of your derived fields"
28+
value={debugText}
29+
onChange={(event) => setDebugText(event.currentTarget.value)}
30+
/>
31+
</InlineField>
32+
{!!debugFields.length && <DebugFields fields={debugFields} />}
33+
</div>
34+
);
35+
};
36+
37+
type DebugFieldItemProps = {
38+
fields: DebugField[];
39+
};
40+
const DebugFields = ({ fields }: DebugFieldItemProps) => {
41+
return (
42+
<table className={'filter-table'}>
43+
<thead>
44+
<tr>
45+
<th>Name</th>
46+
<th>Value</th>
47+
<th>Url</th>
48+
</tr>
49+
</thead>
50+
<tbody>
51+
{fields.map((field) => {
52+
let value: ReactNode = field.value;
53+
if (field.error && field.error instanceof Error) {
54+
value = field.error.message;
55+
} else if (field.href) {
56+
value = <a href={field.href}>{value}</a>;
57+
}
58+
return (
59+
<tr key={`${field.name}=${field.value}`}>
60+
<td>{field.name}</td>
61+
<td>{value}</td>
62+
<td>{field.href ? <a href={field.href}>{field.href}</a> : ''}</td>
63+
</tr>
64+
);
65+
})}
66+
</tbody>
67+
</table>
68+
);
69+
};
70+
71+
type DebugField = {
72+
name: string;
73+
error?: unknown;
74+
value?: string;
75+
href?: string;
76+
};
77+
78+
function makeDebugFields(derivedFields: DerivedFieldConfig[], debugText: string): DebugField[] {
79+
return derivedFields
80+
.filter((field) => field.name && field.matcherRegex)
81+
.map((field) => {
82+
try {
83+
const testMatch = debugText.match(field.matcherRegex);
84+
let href;
85+
const value = testMatch && testMatch[1];
86+
87+
if (value) {
88+
href = getTemplateSrv().replace(field.url, {
89+
__value: {
90+
value: {
91+
raw: value,
92+
},
93+
text: 'Raw value',
94+
},
95+
});
96+
}
97+
const debugFiled: DebugField = {
98+
name: field.name,
99+
value: value || '<no match>',
100+
href,
101+
};
102+
return debugFiled;
103+
} catch (error) {
104+
return {
105+
name: field.name,
106+
error,
107+
};
108+
}
109+
});
110+
}

src/configuration/DerivedField.tsx

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { css } from '@emotion/css';
2+
import { ChangeEvent, useEffect, useState } from 'react';
3+
import * as React from 'react';
4+
import { usePrevious } from 'react-use';
5+
6+
import { GrafanaTheme2, DataSourceInstanceSettings, VariableSuggestion } from '@grafana/data';
7+
import { DataSourcePicker } from '@grafana/runtime';
8+
import { Button, DataLinkInput, Field, Icon, Input, Label, Tooltip, useStyles2, Select, Switch } from '@grafana/ui';
9+
10+
import { DerivedFieldConfig } from '../types';
11+
12+
type MatcherType = 'label' | 'regex';
13+
14+
const getStyles = (theme: GrafanaTheme2) => ({
15+
row: css({
16+
display: 'flex',
17+
alignItems: 'baseline',
18+
}),
19+
nameField: css({
20+
flex: 2,
21+
marginRight: theme.spacing(0.5),
22+
}),
23+
regexField: css({
24+
flex: 3,
25+
marginRight: theme.spacing(0.5),
26+
}),
27+
urlField: css({
28+
flex: 1,
29+
marginRight: theme.spacing(0.5),
30+
}),
31+
urlDisplayLabelField: css({
32+
flex: 1,
33+
}),
34+
internalLink: css({
35+
marginRight: theme.spacing(1),
36+
}),
37+
openNewTab: css({
38+
marginRight: theme.spacing(1),
39+
}),
40+
dataSource: css({}),
41+
nameMatcherField: css({
42+
width: theme.spacing(20),
43+
marginRight: theme.spacing(0.5),
44+
}),
45+
});
46+
47+
type Props = {
48+
value: DerivedFieldConfig;
49+
onChange: (value: DerivedFieldConfig) => void;
50+
onDelete: () => void;
51+
suggestions: VariableSuggestion[];
52+
className?: string;
53+
validateName: (name: string) => boolean;
54+
};
55+
export const DerivedField = (props: Props) => {
56+
const { value, onChange, onDelete, suggestions, className, validateName } = props;
57+
const styles = useStyles2(getStyles);
58+
const [showInternalLink, setShowInternalLink] = useState(!!value.datasourceUid);
59+
const previousUid = usePrevious(value.datasourceUid);
60+
const [fieldType, setFieldType] = useState<MatcherType>(value.matcherType ?? 'regex');
61+
62+
// Force internal link visibility change if uid changed outside of this component.
63+
useEffect(() => {
64+
if (!previousUid && value.datasourceUid && !showInternalLink) {
65+
setShowInternalLink(true);
66+
}
67+
if (previousUid && !value.datasourceUid && showInternalLink) {
68+
setShowInternalLink(false);
69+
}
70+
}, [previousUid, value.datasourceUid, showInternalLink]);
71+
72+
const handleChange = (field: keyof typeof value) => (event: React.ChangeEvent<HTMLInputElement>) => {
73+
onChange({
74+
...value,
75+
[field]: event.currentTarget.value,
76+
});
77+
};
78+
79+
const invalidName = !validateName(value.name);
80+
81+
return (
82+
<div className={className} data-testid="derived-field">
83+
<div className="gf-form">
84+
<Field className={styles.nameField} label="Name" invalid={invalidName} error="The name is already in use">
85+
<Input value={value.name} onChange={handleChange('name')} placeholder="Field name" invalid={invalidName} />
86+
</Field>
87+
<Field
88+
className={styles.nameMatcherField}
89+
label={
90+
<TooltipLabel
91+
label="Type"
92+
content="Derived fields can be created from labels or by applying a regular expression to the log message."
93+
/>
94+
}
95+
>
96+
<Select
97+
options={[
98+
{ label: 'Regex in log line', value: 'regex' },
99+
{ label: 'Label', value: 'label' },
100+
]}
101+
value={fieldType}
102+
onChange={(type) => {
103+
// make sure this is a valid MatcherType
104+
if (type.value === 'label' || type.value === 'regex') {
105+
setFieldType(type.value);
106+
onChange({
107+
...value,
108+
matcherType: type.value,
109+
});
110+
}
111+
}}
112+
/>
113+
</Field>
114+
<Field
115+
className={styles.regexField}
116+
label={
117+
<>
118+
{fieldType === 'regex' && (
119+
<TooltipLabel
120+
label="Regex"
121+
content="Use to parse and capture some part of the log message. You can use the captured groups in the template."
122+
/>
123+
)}
124+
125+
{fieldType === 'label' && <TooltipLabel label="Label" content="Use to derive the field from a label." />}
126+
</>
127+
}
128+
>
129+
<Input value={value.matcherRegex} onChange={handleChange('matcherRegex')} />
130+
</Field>
131+
<Field label="">
132+
<Button
133+
variant="destructive"
134+
title="Remove field"
135+
icon="times"
136+
onClick={(event) => {
137+
event.preventDefault();
138+
onDelete();
139+
}}
140+
/>
141+
</Field>
142+
</div>
143+
144+
<div className="gf-form">
145+
<Field label={showInternalLink ? 'Query' : 'URL'} className={styles.urlField}>
146+
<DataLinkInput
147+
placeholder={showInternalLink ? '${__value.raw}' : 'http://example.com/${__value.raw}'}
148+
value={value.url || ''}
149+
onChange={(newValue) =>
150+
onChange({
151+
...value,
152+
url: newValue,
153+
})
154+
}
155+
suggestions={suggestions}
156+
/>
157+
</Field>
158+
<Field
159+
className={styles.urlDisplayLabelField}
160+
label={
161+
<TooltipLabel
162+
label="URL Label"
163+
content="Use to override the button label when this derived field is found in a log."
164+
/>
165+
}
166+
>
167+
<Input value={value.urlDisplayLabel} onChange={handleChange('urlDisplayLabel')} />
168+
</Field>
169+
</div>
170+
171+
<div className="gf-form">
172+
<Field label="Internal link" className={styles.internalLink}>
173+
<Switch
174+
value={showInternalLink}
175+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
176+
const { checked } = e.currentTarget;
177+
if (!checked) {
178+
onChange({
179+
...value,
180+
datasourceUid: undefined,
181+
});
182+
}
183+
setShowInternalLink(checked);
184+
}}
185+
/>
186+
</Field>
187+
188+
{showInternalLink && (
189+
<Field label="" className={styles.dataSource}>
190+
<DataSourcePicker
191+
tracing={true}
192+
onChange={(ds: DataSourceInstanceSettings) =>
193+
onChange({
194+
...value,
195+
datasourceUid: ds.uid,
196+
})
197+
}
198+
current={value.datasourceUid}
199+
noDefault
200+
/>
201+
</Field>
202+
)}
203+
</div>
204+
</div>
205+
);
206+
};
207+
208+
const TooltipLabel = ({ content, label }: { content: string; label: string }) => (
209+
<Label>
210+
{label}
211+
<Tooltip placement="top" content={content} theme="info">
212+
<Icon tabIndex={0} name="info-circle" size="sm" style={{ marginLeft: '10px' }} />
213+
</Tooltip>
214+
</Label>
215+
);

0 commit comments

Comments
 (0)