Skip to content

Commit 75533ee

Browse files
committed
feat(monitors): allow filtering by anomaly types
1 parent 09f3319 commit 75533ee

6 files changed

Lines changed: 456 additions & 87 deletions

File tree

testgen/ui/components/frontend/js/components/monitor_anomalies_summary.js

Lines changed: 55 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,13 @@
2121
* @property {number} lookback_end
2222
* @property {string?} project_code
2323
* @property {string?} table_group_id
24+
*
25+
* @typedef SummaryOptions
26+
* @type {object}
27+
* @property {function(string)?} onTagClick
28+
* @property {object?} activeTypes
2429
*/
25-
import { emitEvent } from '../utils.js';
30+
import { emitEvent, getValue, loadStylesheet } from '../utils.js';
2631
import { formatDuration, humanReadableDuration } from '../display_utils.js';
2732
import { withTooltip } from './tooltip.js';
2833
import van from '../van.min.js';
@@ -31,49 +36,63 @@ const { a, div, i, span } = van.tags;
3136

3237
/**
3338
* @param {MonitorSummary} summary
34-
* @param {any?} topLabel
39+
* @param {string?} label
40+
* @param {SummaryOptions?} options
3541
*/
36-
const AnomaliesSummary = (summary, label = 'Anomalies') => {
42+
const AnomaliesSummary = (summary, label = 'Anomalies', options = {}) => {
43+
loadStylesheet('anomalies-summary', summaryStylesheet);
44+
3745
if (!summary.lookback) {
3846
return span({class: 'text-secondary mt-3 mb-2'}, 'No monitor runs yet');
3947
}
4048

41-
const SummaryTag = (label, value, hasErrors, isTraining, isPending) => div(
42-
{class: 'flex-row fx-gap-1'},
43-
div(
44-
{class: `flex-row fx-justify-center anomaly-tag ${value > 0 ? 'has-anomalies' : hasErrors ? 'has-errors' : isTraining ? 'is-training' : isPending ? 'is-pending' : ''}`},
45-
value > 0
46-
? value
47-
: hasErrors
48-
? withTooltip(
49-
i({class: 'material-symbols-rounded'}, 'warning'),
50-
{text: 'Execution error', position: 'top-right'},
51-
)
52-
: isTraining
49+
const SummaryTag = (typeKey, tagLabel, value, hasErrors, isTraining, isPending) => {
50+
const isClickable = !!options.onTagClick;
51+
const isActive = van.derive(() => (getValue(options.activeTypes) ?? []).includes(typeKey));
52+
53+
return div(
54+
{
55+
class: () => `flex-row fx-gap-1 p-1 border-radius-1 summary-tag ${isClickable ? 'clickable' : ''} ${isActive.val ? 'active' : ''}`,
56+
onclick: isClickable ? (event) => {
57+
event.stopPropagation();
58+
options.onTagClick(typeKey);
59+
} : undefined,
60+
},
61+
div(
62+
{class: `flex-row fx-justify-center anomaly-tag ${value > 0 ? 'has-anomalies' : hasErrors ? 'has-errors' : isTraining ? 'is-training' : isPending ? 'is-pending' : ''}`},
63+
value > 0
64+
? value
65+
: hasErrors
5366
? withTooltip(
54-
i({class: 'material-symbols-rounded'}, 'more_horiz'),
55-
{text: 'Training model', position: 'top-right'},
67+
i({class: 'material-symbols-rounded'}, 'warning'),
68+
{text: 'Execution error', position: 'top-right'},
5669
)
57-
: isPending
70+
: isTraining
5871
? withTooltip(
59-
span({class: 'pl-2 pr-2', style: 'position: relative;'}, '-'),
60-
{text: 'No results yet or not configured'},
72+
i({class: 'material-symbols-rounded'}, 'more_horiz'),
73+
{text: 'Training model', position: 'top-right'},
6174
)
62-
: i({class: 'material-symbols-rounded'}, 'check'),
63-
),
64-
span({}, label),
65-
);
75+
: isPending
76+
? withTooltip(
77+
span({class: 'pl-2 pr-2', style: 'position: relative;'}, '-'),
78+
{text: 'No results yet or not configured'},
79+
)
80+
: i({class: 'material-symbols-rounded'}, 'check'),
81+
),
82+
span({}, tagLabel),
83+
);
84+
};
6685

6786
const numRuns = summary.lookback === 1 ? 'run' : `${summary.lookback} runs`;
6887
const duration = humanReadableDuration(formatDuration(summary.lookback_start, new Date()), true)
6988
const labelElement = span({class: 'text-small text-secondary'}, `${label} in last ${numRuns} (${duration})`);
7089

7190
const contentElement = div(
7291
{class: 'flex-row fx-gap-5'},
73-
SummaryTag('Freshness', summary.freshness_anomalies, summary.freshness_has_errors, summary.freshness_is_training, summary.freshness_is_pending),
74-
SummaryTag('Volume', summary.volume_anomalies, summary.volume_has_errors, summary.volume_is_training, summary.volume_is_pending),
75-
SummaryTag('Schema', summary.schema_anomalies, summary.schema_has_errors, false, summary.schema_is_pending),
76-
SummaryTag('Metrics', summary.metric_anomalies, summary.metric_has_errors, summary.metric_is_training, summary.metric_is_pending),
92+
SummaryTag('freshness', 'Freshness', summary.freshness_anomalies, summary.freshness_has_errors, summary.freshness_is_training, summary.freshness_is_pending),
93+
SummaryTag('volume', 'Volume', summary.volume_anomalies, summary.volume_has_errors, summary.volume_is_training, summary.volume_is_pending),
94+
SummaryTag('schema', 'Schema', summary.schema_anomalies, summary.schema_has_errors, false, summary.schema_is_pending),
95+
SummaryTag('metrics', 'Metrics', summary.metric_anomalies, summary.metric_has_errors, summary.metric_is_training, summary.metric_is_pending),
7796
);
7897

7998
if (summary.project_code && summary.table_group_id) {
@@ -96,4 +115,12 @@ const AnomaliesSummary = (summary, label = 'Anomalies') => {
96115
return div({class: 'flex-column fx-gap-2'}, labelElement, contentElement);
97116
};
98117

118+
const summaryStylesheet = new CSSStyleSheet();
119+
summaryStylesheet.replace(`
120+
.summary-tag.clickable:hover,
121+
.summary-tag.active {
122+
background: var(--select-hover-background);
123+
}
124+
`);
125+
99126
export { AnomaliesSummary };

testgen/ui/components/frontend/js/components/select.js

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@
99
* @type {object}
1010
* @property {string?} id
1111
* @property {string} label
12-
* @property {string?} value
12+
* @property {string?|Array.<string>?} value
1313
* @property {Array.<SelectOption>} options
1414
* @property {boolean} allowNull
1515
* @property {Function|null} onChange
1616
* @property {boolean?} disabled
1717
* @property {boolean?} required
18+
* @property {boolean?} multiSelect
1819
* @property {number?} width
1920
* @property {number?} height
2021
* @property {string?} style
@@ -34,6 +35,10 @@ const { div, i, input, label, span } = van.tags;
3435
const Select = (/** @type {Properties} */ props) => {
3536
loadStylesheet('select', stylesheet);
3637

38+
if (getValue(props.multiSelect)) {
39+
return MultiSelect(props);
40+
}
41+
3742
const domId = van.derive(() => props.id?.val ?? getRandomId());
3843
const opened = van.state(false);
3944
const optionsFilter = van.state('');
@@ -207,6 +212,112 @@ const Select = (/** @type {Properties} */ props) => {
207212
);
208213
};
209214

215+
/**
216+
* @param {Properties} props
217+
*/
218+
const MultiSelect = (props) => {
219+
const domId = van.derive(() => props.id?.val ?? getRandomId());
220+
const opened = van.state(false);
221+
const options = van.derive(() => getValue(props.options) ?? []);
222+
223+
const selectedValues = isState(props.value) ? props.value : van.state(props.value ?? []);
224+
225+
const displayLabel = van.derive(() => {
226+
const selected = getValue(selectedValues) ?? [];
227+
if (!selected.length) {
228+
return '---';
229+
};
230+
const allOptions = getValue(options);
231+
return selected
232+
.map(value => allOptions.find(opt => opt.value === value)?.label ?? value)
233+
.join(', ');
234+
});
235+
236+
const toggleOption = (optionValue) => {
237+
const current = [...(getValue(selectedValues) ?? [])];
238+
const index = current.indexOf(optionValue);
239+
if (index >= 0) {
240+
current.splice(index, 1);
241+
} else {
242+
current.push(optionValue);
243+
}
244+
selectedValues.val = current;
245+
props.onChange?.(current, { valid: current.length > 0 || !getValue(props.required) });
246+
};
247+
248+
return div(
249+
{
250+
id: domId,
251+
class: () => `flex-column fx-gap-1 text-caption tg-select--label ${getValue(props.disabled) ? 'disabled' : ''}`,
252+
style: () => `width: ${props.width ? getValue(props.width) + 'px' : 'auto'}; ${getValue(props.style)}`,
253+
'data-testid': getValue(props.testId) ?? '',
254+
onclick: (/** @type Event */ event) => {
255+
event.stopPropagation();
256+
event.stopImmediatePropagation();
257+
// Should toggle open/close unless disabled
258+
opened.val = getValue(props.disabled) ? false : !opened.val;
259+
},
260+
},
261+
span(
262+
{ class: 'flex-row fx-gap-1', 'data-testid': 'select-label' },
263+
props.label,
264+
() => getValue(props.required)
265+
? span({ class: 'text-error' }, '*')
266+
: '',
267+
),
268+
269+
div(
270+
{
271+
class: () => `flex-row tg-select--field ${opened.val ? 'opened' : ''}`,
272+
style: () => getValue(props.height) ? `height: ${getValue(props.height)}px;` : '',
273+
'data-testid': 'select-input',
274+
},
275+
() => {
276+
// Hack to display value again when closed
277+
// For some reason, it goes away when opened
278+
opened.val;
279+
return div(
280+
{ class: 'tg-select--field--content tg-select--multi-display', 'data-testid': 'select-input-display' },
281+
displayLabel.val || '',
282+
);
283+
},
284+
div(
285+
{ class: 'tg-select--field--icon', 'data-testid': 'select-input-trigger' },
286+
i({ class: 'material-symbols-rounded' }, 'expand_more'),
287+
),
288+
),
289+
290+
Portal(
291+
{target: domId.val, targetRelative: true, position: props.portalPosition?.val ?? props?.portalPosition, opened},
292+
() => div(
293+
{
294+
class: () => `tg-select--options-wrapper mt-1 ${getValue(props.portalClass) ?? ''}`,
295+
'data-testid': 'select-options',
296+
},
297+
getValue(options).map(option => {
298+
const isSelected = van.derive(() => (getValue(selectedValues) ?? []).includes(option.value));
299+
return div(
300+
{
301+
class: () => `tg-select--option fx-gap-2 ${isSelected.val ? 'selected' : ''}`,
302+
onclick: (/** @type Event */ event) => {
303+
event.stopPropagation();
304+
toggleOption(option.value);
305+
},
306+
'data-testid': 'select-options-item',
307+
},
308+
input({
309+
type: 'checkbox',
310+
class: 'tg-select--checkbox',
311+
checked: isSelected,
312+
}),
313+
span(option.label),
314+
);
315+
}),
316+
),
317+
),
318+
);
319+
};
320+
210321
const stylesheet = new CSSStyleSheet();
211322
stylesheet.replace(`
212323
.tg-select--label {
@@ -248,6 +359,12 @@ stylesheet.replace(`
248359
font-weight: 500;
249360
}
250361
362+
.tg-select--multi-display {
363+
overflow: hidden;
364+
text-overflow: ellipsis;
365+
white-space: nowrap;
366+
}
367+
251368
.tg-select--field--content > input {
252369
border: unset !important;
253370
background: transparent !important;
@@ -308,6 +425,36 @@ stylesheet.replace(`
308425
color: var(--primary-color);
309426
}
310427
428+
.tg-select--checkbox {
429+
appearance: none;
430+
box-sizing: border-box;
431+
margin: 0;
432+
width: 18px;
433+
height: 18px;
434+
flex-shrink: 0;
435+
border: 1px solid var(--secondary-text-color);
436+
border-radius: 4px;
437+
position: relative;
438+
pointer-events: none;
439+
transition-property: border-color, background-color;
440+
transition-duration: 0.3s;
441+
}
442+
443+
.tg-select--checkbox:checked {
444+
border-color: transparent;
445+
background-color: var(--primary-color);
446+
}
447+
448+
.tg-select--checkbox:checked::after {
449+
content: 'check';
450+
position: absolute;
451+
top: -4px;
452+
left: -3px;
453+
font-family: 'Material Symbols Rounded';
454+
font-size: 22px;
455+
color: white;
456+
}
457+
311458
.tg-select--inline-trigger {
312459
border-bottom: 1px solid var(--border-color);
313460
}

testgen/ui/components/frontend/js/pages/monitors_dashboard.js

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
* @type {object}
4949
* @property {string?} table_group_id
5050
* @property {string?} table_name_filter
51-
* @property {string?} only_tables_with_anomalies
51+
* @property {string?} anomaly_type_filter
5252
*
5353
* @typedef MonitorListSort
5454
* @type {object}
@@ -85,7 +85,6 @@ import { Checkbox } from '../components/checkbox.js';
8585
import { EmptyState, EMPTY_STATE_MESSAGE } from '../components/empty_state.js';
8686
import { Icon } from '../components/icon.js';
8787
import { Table } from '../components/table.js';
88-
import { Toggle } from '../components/toggle.js';
8988
import { withTooltip } from '../components/tooltip.js';
9089
import { AnomaliesSummary } from '../components/monitor_anomalies_summary.js';
9190

@@ -100,7 +99,7 @@ const MonitorsDashboard = (/** @type Properties */ props) => {
10099
let renderTime = new Date();
101100
const tableGroupFilterValue = van.derive(() => getValue(props.filters).table_group_id ?? null);
102101
const tableNameFilterValue = van.derive(() => getValue(props.filters).table_name_filter ?? null);
103-
const onlyAnomaliesFilterValue = van.derive(() => getValue(props.filters).only_tables_with_anomalies === 'true');
102+
const anomalyTypeFilterValue = van.derive(() => getValue(props.filters).anomaly_type_filter ?? []);
104103
const tableSort = van.derive(() => {
105104
const sort = getValue(props.sort);
106105
return {
@@ -292,7 +291,14 @@ const MonitorsDashboard = (/** @type Properties */ props) => {
292291
onChange: (value) => emitEvent('SetParamValues', {payload: {table_group_id: value}}),
293292
}),
294293
() => getValue(props.has_monitor_test_suite)
295-
? AnomaliesSummary(getValue(props.summary), 'Total anomalies')
294+
? AnomaliesSummary(getValue(props.summary), 'Total anomalies', {
295+
onTagClick: (type) => {
296+
const current = anomalyTypeFilterValue.val;
297+
const newFilter = current.length === 1 && current[0] === type ? null : type;
298+
emitEvent('SetParamValues', { payload: { anomaly_type_filter: newFilter } });
299+
},
300+
activeTypes: anomalyTypeFilterValue,
301+
})
296302
: '',
297303
() => getValue(props.has_monitor_test_suite) && userCanEdit
298304
? div(
@@ -330,7 +336,7 @@ const MonitorsDashboard = (/** @type Properties */ props) => {
330336
() => getValue(props.has_monitor_test_suite) ? Table(
331337
{
332338
header: () => div(
333-
{class: 'flex-row fx-gap-3 p-4 pt-2 pb-2'},
339+
{class: 'flex-row fx-align-flex-end fx-gap-3 p-4 pt-2 pb-2'},
334340
Input({
335341
id: 'search-tables',
336342
name: 'search-tables',
@@ -343,12 +349,20 @@ const MonitorsDashboard = (/** @type Properties */ props) => {
343349
value: tableNameFilterValue,
344350
onChange: (value, state) => emitEvent('SetParamValues', {payload: {table_name_filter: value}}),
345351
}),
346-
Toggle({
347-
name: 'anomalies_only',
348-
label: 'Only tables with anomalies',
349-
style: 'font-size: 16px;',
350-
checked: onlyAnomaliesFilterValue,
351-
onChange: (checked) => emitEvent('SetParamValues', {payload: {only_tables_with_anomalies: String(checked).toLowerCase()}}),
352+
Select({
353+
label: 'Anomaly type',
354+
value: anomalyTypeFilterValue,
355+
options: [
356+
{ label: 'Freshness', value: 'freshness' },
357+
{ label: 'Volume', value: 'volume' },
358+
{ label: 'Schema', value: 'schema' },
359+
{ label: 'Metrics', value: 'metrics' },
360+
],
361+
multiSelect: true,
362+
width: 200,
363+
onChange: (values) => emitEvent('SetParamValues', {
364+
payload: { anomaly_type_filter: values.length ? values.join(',') : null },
365+
}),
352366
}),
353367
span({class: 'fx-flex'}, ''),
354368
() => {

0 commit comments

Comments
 (0)