Execute application label filtering on client side (#2605)

This commit is contained in:
Alexander Matyushentsev 2019-10-30 18:42:04 -07:00 committed by Alex Collins
parent 6930ecc947
commit 89b33a1442
6 changed files with 96 additions and 10 deletions

View file

@ -4,5 +4,6 @@
"printWidth": 180,
"singleQuote": true,
"tabWidth": 4,
"jsxBracketSameLine": true
"jsxBracketSameLine": true,
"quoteProps": "consistent"
}

View file

@ -193,7 +193,7 @@ function renderResourceNode(props: ApplicationResourceTreeProps, id: string, nod
<div
onClick={() => props.onNodeClick && props.onNodeClick(fullName)}
className={classNames('application-resource-tree__node', {
active: fullName === props.selectedNodeFullName,
'active': fullName === props.selectedNodeFullName,
'application-resource-tree__node--orphaned': node.orphaned
})}
style={{left: node.x, top: node.y, width: node.width, height: node.height}}>

View file

@ -12,6 +12,7 @@ import {AppsListPreferences, AppsListViewType, services} from '../../../shared/s
import {ApplicationCreatePanel} from '../application-create-panel/application-create-panel';
import {ApplicationSyncPanel} from '../application-sync-panel/application-sync-panel';
import {ApplicationsSyncPanel} from '../applications-sync-panel/applications-sync-panel';
import * as LabelSelector from '../label-selector';
import * as AppUtils from '../utils';
import {ApplicationsFilter} from './applications-filter';
import {ApplicationsSummary} from './applications-summary';
@ -38,12 +39,12 @@ const APP_FIELDS = [
const APP_LIST_FIELDS = APP_FIELDS.map(field => `items.${field}`);
const APP_WATCH_FIELDS = ['result.type', ...APP_FIELDS.map(field => `result.application.${field}`)];
function loadApplications(selector: string): Observable<models.Application[]> {
return Observable.fromPromise(services.applications.list([], {fields: APP_LIST_FIELDS, selector})).flatMap(applications =>
function loadApplications(): Observable<models.Application[]> {
return Observable.fromPromise(services.applications.list([], {fields: APP_LIST_FIELDS})).flatMap(applications =>
Observable.merge(
Observable.from([applications]),
services.applications
.watch(null, {fields: APP_WATCH_FIELDS, selector})
.watch(null, {fields: APP_WATCH_FIELDS})
.map(appChange => {
const index = applications.findIndex(item => item.metadata.name === appChange.application.metadata.name);
if (index > -1 && appChange.application.metadata.resourceVersion === applications[index].metadata.resourceVersion) {
@ -116,6 +117,7 @@ const ViewPref = ({children}: {children: (pref: AppsListPreferences & {page: num
viewPref.labelsFilter = params
.get('labels')
.split(',')
.map(decodeURIComponent)
.filter(item => !!item);
}
return {...viewPref, page: parseInt(params.get('page') || '0', 10), search: params.get('search') || ''};
@ -136,7 +138,8 @@ function filterApps(applications: models.Application[], pref: AppsListPreference
(pref.syncFilter.length === 0 || pref.syncFilter.includes(app.status.sync.status)) &&
(pref.healthFilter.length === 0 || pref.healthFilter.includes(app.status.health.status)) &&
(pref.namespacesFilter.length === 0 || pref.namespacesFilter.some(ns => minimatch(app.spec.destination.namespace, ns))) &&
(pref.clustersFilter.length === 0 || pref.clustersFilter.some(server => minimatch(app.spec.destination.server, server)))
(pref.clustersFilter.length === 0 || pref.clustersFilter.some(server => minimatch(app.spec.destination.server, server))) &&
(pref.labelsFilter.length === 0 || pref.labelsFilter.every(selector => LabelSelector.match(selector, app.metadata.labels)))
);
}
@ -209,8 +212,7 @@ export const ApplicationsList = (props: RouteComponentProps<{}>) => {
<ViewPref>
{pref => (
<DataLoader
input={(pref.labelsFilter || []).join(',')}
load={selector => loadApplications(selector)}
load={() => loadApplications()}
loadingRenderer={() => (
<div className='argo-container'>
<MockupList height={100} marginTop={30} />
@ -275,7 +277,7 @@ export const ApplicationsList = (props: RouteComponentProps<{}>) => {
health: newPref.healthFilter.join(','),
namespace: newPref.namespacesFilter.join(','),
cluster: newPref.clustersFilter.join(','),
labels: newPref.labelsFilter.join(',')
labels: newPref.labelsFilter.map(encodeURIComponent).join(',')
});
}}
/>

View file

@ -20,7 +20,7 @@ export const ApplicationsSyncPanel = ({show, apps, hide}: {show: boolean; apps:
<div>
<button className='argo-button argo-button--base' onClick={() => form.submitForm(null)}>
Sync
</button>
</button>{' '}
<button onClick={() => hide()} className='argo-button argo-button--base-o'>
Cancel
</button>

View file

@ -0,0 +1,44 @@
import * as LabelSelector from './label-selector';
test('exists', () => {
expect(LabelSelector.match('test', {test: 'hello'})).toBeTruthy();
expect(LabelSelector.match('test1', {test: 'hello'})).toBeFalsy();
});
test('not exists', () => {
expect(LabelSelector.match('!test', {test: 'hello'})).toBeFalsy();
expect(LabelSelector.match('!test1', {test: 'hello'})).toBeTruthy();
});
test('in', () => {
expect(LabelSelector.match('test in 1, 2, 3', {test: '1'})).toBeTruthy();
expect(LabelSelector.match('test in 1, 2, 3', {test: '4'})).toBeFalsy();
expect(LabelSelector.match('test in 1, 2, 3', {test1: '1'})).toBeFalsy();
});
test('notIn', () => {
expect(LabelSelector.match('test notin 1, 2, 3', {test: '1'})).toBeFalsy();
expect(LabelSelector.match('test notin 1, 2, 3', {test: '4'})).toBeTruthy();
expect(LabelSelector.match('test notin 1, 2, 3', {test1: '1'})).toBeTruthy();
});
test('equal', () => {
expect(LabelSelector.match('test=hello', {test: 'hello'})).toBeTruthy();
expect(LabelSelector.match('test=world', {test: 'hello'})).toBeFalsy();
expect(LabelSelector.match('test==hello', {test: 'hello'})).toBeTruthy();
});
test('notEqual', () => {
expect(LabelSelector.match('test!=hello', {test: 'hello'})).toBeFalsy();
expect(LabelSelector.match('test!=world', {test: 'hello'})).toBeTruthy();
});
test('greaterThen', () => {
expect(LabelSelector.match('test gt 1', {test: '2'})).toBeTruthy();
expect(LabelSelector.match('test gt 3', {test: '2'})).toBeFalsy();
});
test('lessThen', () => {
expect(LabelSelector.match('test lt 1', {test: '2'})).toBeFalsy();
expect(LabelSelector.match('test lt 3', {test: '2'})).toBeTruthy();
});

View file

@ -0,0 +1,39 @@
type operatorFn = (labels: {[name: string]: string}, key: string, values: string[]) => boolean;
const operators: {[type: string]: operatorFn} = {
'!=': (labels, key, values) => labels[key] !== values[0],
'==': (labels, key, values) => labels[key] === values[0],
'=': (labels, key, values) => labels[key] === values[0],
'notin': (labels, key, values) => !values.includes(labels[key]),
'in': (labels, key, values) => values.includes(labels[key]),
'gt': (labels, key, values) => parseFloat(labels[key]) > parseFloat(values[0]),
'lt': (labels, key, values) => parseFloat(labels[key]) < parseFloat(values[0])
};
function split(input: string, delimiter: string): string[] {
return input
.split(delimiter)
.map(part => part.trim())
.filter(part => part !== '');
}
export type LabelSelector = (labels: {[name: string]: string}) => boolean;
export function parse(selector: string): LabelSelector {
for (const type of Object.keys(operators)) {
const operator = operators[type];
const parts = split(selector, type);
if (parts.length > 1) {
const values = split(parts[1], ',');
return labels => operator(labels, parts[0], values);
}
}
if (selector.startsWith('!')) {
return labels => !labels.hasOwnProperty(selector.slice(1));
}
return labels => labels.hasOwnProperty(selector);
}
export function match(selector: string, labels: {[name: string]: string}): boolean {
return parse(selector)(labels || {});
}