feat(ui): Adds resource filter panel. Fixes #6379 #6331 #6081 (#6717)

Signed-off-by: Alex Collins <alex_collins@intuit.com>
This commit is contained in:
Alex Collins 2021-07-14 16:01:12 -07:00 committed by GitHub
parent 1b46143d07
commit 2b3601cfa3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 220 additions and 55 deletions

View file

@ -430,7 +430,7 @@ start-e2e-local:
ARGOCD_TLS_DATA_PATH=/tmp/argo-e2e/app/config/tls \
ARGOCD_GPG_DATA_PATH=/tmp/argo-e2e/app/config/gpg/source \
ARGOCD_GNUPGHOME=/tmp/argo-e2e/app/config/gpg/keys \
ARGOCD_GPG_ENABLED=true \
ARGOCD_GPG_ENABLED=$(ARGOCD_GPG_ENABLED) \
ARGOCD_E2E_DISABLE_AUTH=false \
ARGOCD_ZJWT_FEATURE_FLAG=always \
ARGOCD_IN_CI=$(ARGOCD_IN_CI) \
@ -463,7 +463,7 @@ start-local: mod-vendor-local dep-ui-local
mkdir -p /tmp/argocd-local/gpg/source
ARGOCD_ZJWT_FEATURE_FLAG=always \
ARGOCD_IN_CI=false \
ARGOCD_GPG_ENABLED=true \
ARGOCD_GPG_ENABLED=$(ARGOCD_GPG_ENABLED) \
ARGOCD_E2E_TEST=false \
goreman -f $(ARGOCD_PROCFILE) start ${ARGOCD_START}

View file

@ -1,4 +1,4 @@
import {Checkbox as ArgoCheckbox, DropDownMenu, NotificationType, SlidingPanel, TopBarFilter} from 'argo-ui';
import {Checkbox as ArgoCheckbox, DropDownMenu, NotificationType, SlidingPanel} from 'argo-ui';
import * as classNames from 'classnames';
import * as PropTypes from 'prop-types';
import * as React from 'react';
@ -9,6 +9,7 @@ import {delay, filter, map, mergeMap, repeat, retryWhen} from 'rxjs/operators';
import {DataLoader, EmptyState, ErrorNotification, ObservableQuery, Page, Paginate, Revision, Timestamp} from '../../../shared/components';
import {AppContext, ContextApis} from '../../../shared/context';
import * as appModels from '../../../shared/models';
import {ApplicationTree} from '../../../shared/models';
import {AppDetailsPreferences, AppsDetailsViewType, services} from '../../../shared/services';
import {ApplicationConditions} from '../application-conditions/application-conditions';
@ -21,6 +22,7 @@ import {ApplicationSyncPanel} from '../application-sync-panel/application-sync-p
import {ResourceDetails} from '../resource-details/resource-details';
import * as AppUtils from '../utils';
import {ApplicationResourceList} from './application-resource-list';
import {Filters} from './filters';
require('./application-details.scss');
@ -29,6 +31,15 @@ interface ApplicationDetailsState {
revision?: string;
}
interface FilterInput {
kind: string[];
health: string[];
sync: string[];
namespace: string[];
createdWithin: number[]; // number of minutes the resource must be created within
ownership: string[];
}
export const NodeInfo = (node?: string): {key: string; container: number} => {
const nodeContainer = {key: '', container: 0};
if (node) {
@ -108,44 +119,13 @@ export class ApplicationDetails extends React.Component<RouteComponentProps<{nam
}>
{({application, tree, pref}: {application: appModels.Application; tree: appModels.ApplicationTree; pref: AppDetailsPreferences}) => {
tree.nodes = tree.nodes || [];
const kindsSet = new Set<string>(tree.nodes.map(item => item.kind));
const treeFilter = this.getTreeFilter(pref.resourceFilter);
treeFilter.kind.forEach(kind => {
kindsSet.add(kind);
});
const kinds = Array.from(kindsSet);
const noKindsFilter = pref.resourceFilter.filter(item => item.indexOf('kind:') !== 0);
const refreshing = application.metadata.annotations && application.metadata.annotations[appModels.AnnotationRefreshKey];
const filterTopBar: TopBarFilter<string> = {
items: [
{content: () => <span>Sync</span>},
{value: 'sync:Synced', label: 'Synced'},
// Unhealthy includes 'Unknown' and 'OutOfSync'
{value: 'sync:OutOfSync', label: 'OutOfSync'},
{content: () => <span>Health</span>},
{value: 'health:Healthy', label: 'Healthy'},
{value: 'health:Progressing', label: 'Progressing'},
{value: 'health:Degraded', label: 'Degraded'},
{value: 'health:Missing', label: 'Missing'},
{value: 'health:Unknown', label: 'Unknown'},
{
content: setSelection => (
<div>
Kinds <a onClick={() => setSelection(noKindsFilter.concat(kinds.map(kind => `kind:${kind}`)))}>all</a> /{' '}
<a onClick={() => setSelection(noKindsFilter)}>none</a>
</div>
)
},
...kinds.sort().map(kind => ({value: `kind:${kind}`, label: kind}))
],
selectedValues: pref.resourceFilter,
selectionChanged: items => {
this.appContext.apis.navigation.goto('.', {resource: `${items.join(',')}`});
services.viewPreferences.updatePreferences({appDetails: {...pref, resourceFilter: items}});
}
const setFilter = (items: string[]) => {
this.appContext.apis.navigation.goto('.', {resource: items.join(',')});
services.viewPreferences.updatePreferences({appDetails: {...pref, resourceFilter: items}});
};
const clearFilter = () => setFilter([]);
const refreshing = application.metadata.annotations && application.metadata.annotations[appModels.AnnotationRefreshKey];
const appNodesByName = this.groupAppNodesByKey(application, tree);
const selectedItem = (this.selectedNodeKey && appNodesByName.get(this.selectedNodeKey)) || null;
const isAppSelected = selectedItem === application;
@ -166,7 +146,7 @@ export class ApplicationDetails extends React.Component<RouteComponentProps<{nam
.filter(res => {
const resNode: ResourceTreeNode = {...res, root: null, info: null, parentRefs: [], resourceVersion: '', uid: ''};
resNode.root = resNode;
return this.filterTreeNode(resNode, treeFilter);
return this.filterTreeNode(tree, resNode, treeFilter);
})
.concat(orphans);
@ -175,7 +155,6 @@ export class ApplicationDetails extends React.Component<RouteComponentProps<{nam
<Page
title='Application Details'
toolbar={{
filter: filterTopBar,
breadcrumbs: [{title: 'Applications', path: '/applications'}, {title: this.props.match.params.name}],
actionMenu: {items: this.getApplicationActionMenu(application)},
tools: (
@ -227,6 +206,7 @@ export class ApplicationDetails extends React.Component<RouteComponentProps<{nam
</div>
<div className='application-details__tree'>
{refreshing && <p className='application-details__refreshing-label'>Refreshing</p>}
<Filters pref={pref} tree={tree} onSetFilter={setFilter} onClearFilter={clearFilter} />
{(tree.orphanedNodes || []).length > 0 && (
<div className='application-details__orphaned-filter'>
<ArgoCheckbox
@ -242,7 +222,7 @@ export class ApplicationDetails extends React.Component<RouteComponentProps<{nam
)}
{((pref.view === 'tree' || pref.view === 'network') && (
<ApplicationResourceTree
nodeFilter={node => this.filterTreeNode(node, treeFilter)}
nodeFilter={node => this.filterTreeNode(tree, node, treeFilter)}
selectedNodeFullName={this.selectedNodeKey}
onNodeClick={fullName => this.selectNode(fullName)}
nodeMenu={node =>
@ -254,10 +234,7 @@ export class ApplicationDetails extends React.Component<RouteComponentProps<{nam
app={application}
showOrphanedResources={pref.orphanedResources}
useNetworkingHierarchy={pref.view === 'network'}
onClearFilter={() => {
this.appContext.apis.navigation.goto('.', {resource: ''});
services.viewPreferences.updatePreferences({appDetails: {...pref, resourceFilter: []}});
}}
onClearFilter={clearFilter}
/>
)) ||
(pref.view === 'pods' && (
@ -459,14 +436,43 @@ export class ApplicationDetails extends React.Component<RouteComponentProps<{nam
];
}
private filterTreeNode(node: ResourceTreeNode, filterInput: {kind: string[]; health: string[]; sync: string[]}): boolean {
private filterTreeNode(tree: ApplicationTree, node: ResourceTreeNode, filterInput: FilterInput, ownership?: string): boolean {
const syncStatuses = filterInput.sync.map(item => (item === 'OutOfSync' ? ['OutOfSync', 'Unknown'] : [item])).reduce((first, second) => first.concat(second), []);
return (
const minutesAgo = (m: number) => {
const d = new Date();
d.setTime(d.getTime() - m * 60000);
return d;
};
const createdAt = new Date(node.createdAt); // will be falsely if the node has not been created, and so will not appear
const createdWithin = (n: number) => createdAt.getTime() > minutesAgo(n).getTime();
const root = node.root || ({} as ResourceTreeNode);
const hook = root && root.hook;
if (
(filterInput.kind.length === 0 || filterInput.kind.indexOf(node.kind) > -1) &&
(syncStatuses.length === 0 || node.root.hook || (node.root.status && syncStatuses.indexOf(node.root.status) > -1)) &&
(filterInput.health.length === 0 || node.root.hook || (node.root.health && filterInput.health.indexOf(node.root.health.status) > -1))
);
(syncStatuses.length === 0 || hook || (root.status && syncStatuses.indexOf(root.status) > -1)) &&
(filterInput.health.length === 0 || hook || (root.health && filterInput.health.indexOf(root.health.status) > -1)) &&
(filterInput.namespace.length === 0 || filterInput.namespace.includes(node.namespace)) &&
(filterInput.createdWithin.length === 0 || !!filterInput.createdWithin.find(v => createdWithin(v)))
) {
return true;
}
if (filterInput.ownership.includes('Owned') && ownership !== 'Owners') {
const owned = tree.nodes.filter(n => (node.parentRefs || []).find(r => r.uid === n.uid));
if (owned.find(n => this.filterTreeNode(tree, n, filterInput, 'Owned'))) {
return true;
}
}
if (filterInput.ownership.includes('Owners') && ownership !== 'Owned') {
const owners = tree.nodes.filter(n => (n.parentRefs || []).find(r => r.uid === node.uid));
if (owners.find(n => this.filterTreeNode(tree, n, filterInput, 'Owners'))) {
return true;
}
}
return false;
}
private loadAppInfo(name: string): Observable<{application: appModels.Application; tree: appModels.ApplicationTree}> {
@ -535,11 +541,14 @@ export class ApplicationDetails extends React.Component<RouteComponentProps<{nam
return nodeByKey;
}
private getTreeFilter(filterInput: string[]): {kind: string[]; health: string[]; sync: string[]} {
private getTreeFilter(filterInput: string[]): FilterInput {
const kind = new Array<string>();
const health = new Array<string>();
const sync = new Array<string>();
for (const item of filterInput) {
const namespace = new Array<string>();
const createdWithin = new Array<number>();
const ownership = new Array<string>();
for (const item of filterInput || []) {
const [type, val] = item.split(':');
switch (type) {
case 'kind':
@ -551,9 +560,18 @@ export class ApplicationDetails extends React.Component<RouteComponentProps<{nam
case 'sync':
sync.push(val);
break;
case 'namespace':
namespace.push(val);
break;
case 'createdWithin':
createdWithin.push(parseInt(val, 10));
break;
case 'ownership':
ownership.push(val);
break;
}
}
return {kind, health, sync};
return {kind, health, sync, namespace, createdWithin, ownership};
}
private setOperationStatusVisible(isVisible: boolean) {

View file

@ -0,0 +1,147 @@
import {HelpIcon} from 'argo-ui';
import * as React from 'react';
import {useContext} from 'react';
import {Context} from '../../../shared/context';
import {ApplicationTree} from '../../../shared/models';
import {AppDetailsPreferences} from '../../../shared/services';
const uniq = (value: string, index: number, self: string[]) => self.indexOf(value) === index;
export const Filters = ({
pref,
tree,
onSetFilter,
onClearFilter
}: {
pref: AppDetailsPreferences;
tree: ApplicationTree;
onSetFilter: (items: string[]) => void;
onClearFilter: () => void;
}) => {
const {history, navigation} = useContext(Context);
const shown = new URLSearchParams(history.location.search).get('showFilters') === 'true';
const setShown = (showFilters: boolean) => navigation.goto('.', {showFilters});
const resourceFilter = pref.resourceFilter || [];
const hasPrefix = (prefix: string) => (v: string) => v.startsWith(prefix + ':');
const removePrefix = (prefix: string) => (v: string) => v.replace(prefix + ':', '');
const anyFiltered = pref.resourceFilter.length > 0;
const isFiltered = (prefix: string, suffix: string) => resourceFilter.includes(`${prefix}:${suffix}`);
const anyPrefixFiltered = (prefix: string) => resourceFilter.find(hasPrefix(prefix));
const setFilters = (prefix: string, suffixes: string[], v: boolean) => {
const filters = suffixes.map(suffix => `${prefix}:${suffix}`);
const items = resourceFilter.filter(y => !filters.includes(y));
if (v) {
items.push(...filters);
}
onSetFilter(items);
};
const enableFilter = (prefix: string, suffix: string) => {
const items = resourceFilter.filter(v => !hasPrefix(prefix)(v)).concat([`${prefix}:${suffix}`]);
onSetFilter(items);
};
// this is smarter than it looks at first glance, rather than just un-checked known items,
// it instead finds out what is enabled, and then removes them, which will be tolerant to weird or unknown items
const clearFilters = (prefix: string) => {
return setFilters(prefix, resourceFilter.filter(hasPrefix(prefix)).map(removePrefix(prefix)), false);
};
// we need to include ones that might have been filter in other apps that do not apply to the current app,
// otherwise the user will not be able to clear them from this panel
const alreadyFilteredOn = (prefix: string) => resourceFilter.filter(hasPrefix(prefix)).map(removePrefix(prefix));
const kinds = tree.nodes
.map(x => x.kind)
.concat(alreadyFilteredOn('kind'))
.filter(uniq)
.sort();
const namespaces = tree.nodes
.map(x => x.namespace)
.concat(alreadyFilteredOn('namespace'))
.filter(uniq)
.sort();
const checkbox = (prefix: string, suffix: string, label: string = null) => (
<label key={suffix} style={{display: 'inline-block', paddingRight: 5}}>
<input type='checkbox' checked={isFiltered(prefix, suffix)} onChange={() => setFilters(prefix, [suffix], !isFiltered(prefix, suffix))} /> {label || suffix}
</label>
);
const checkboxes = (prefix: string, suffixes: string[]) => suffixes.map(suffix => checkbox(prefix, suffix));
const radiobox = (prefix: string, suffix: string, label: string) => (
<label key={suffix}>
<input type='radio' onChange={() => enableFilter(prefix, suffix)} checked={isFiltered(prefix, suffix)} /> {label}{' '}
</label>
);
const clearFilterLink = (prefix: string) =>
anyPrefixFiltered(prefix) && (
<small style={{marginLeft: 'auto'}}>
<a onClick={() => clearFilters(prefix)}>clear</a>
</small>
);
return (
<>
<div className='applications-list__filters__title'>
FILTERS <i className='fa fa-filter' />
{anyFiltered && (
<small>
<a onClick={() => onClearFilter()}>clear all</a>
</small>
)}
<a onClick={() => setShown(!shown)} style={{marginLeft: 'auto', fontSize: '12px', lineHeight: '5px', display: shown && 'block'}}>
{!shown ? 'SHOW' : 'HIDE'}
</a>
</div>
{shown && (
<div className='applications-list__filters'>
<div className='filter'>
<div className='filter__header'>
OWNERSHIP
<HelpIcon
title='Always show resources that own/owned by resources that will be shown.
For example, if you you want to find what owns pod, so you select "pods" ond choose "Owners"'
/>
{clearFilterLink('ownership')}
</div>
<div>{checkboxes('ownership', ['Owners', 'Owned'])}</div>
</div>
<div className='filter'>
<div className='filter__header'>SYNC STATUS {clearFilterLink('sync')}</div>
<div>{checkboxes('sync', ['Synced', 'OutOfSync'])}</div>
</div>
<div className='filter'>
<div className='filter__header'>HEALTH STATUS {clearFilterLink('health')}</div>
<div>{checkboxes('health', ['Healthy', 'Progressing', 'Degraded', 'Suspended', 'Missing', 'Unknown'])}</div>
</div>
<div className='filter'>
<div className='filter__header'>
KINDS
<small style={{marginLeft: 'auto'}}>
<a onClick={() => setFilters('kind', kinds, true)}>all</a> {anyPrefixFiltered('kind') && <a onClick={() => clearFilters('kind')}>clear</a>}
</small>
</div>
<div>{checkboxes('kind', kinds)}</div>
</div>
{namespaces.length > 1 && (
<div className='filter'>
<div className='filter__header'>NAMESPACE {clearFilterLink('namespace')}</div>
<div>{checkboxes('namespace', namespaces)}</div>
</div>
)}
<div className='filter'>
<div className='filter__header'>
CREATED WITHIN
<HelpIcon title='Use this to find recently created resources, if you want recently synced resources, please raise an issue' />
{clearFilterLink('createdWithin')}
</div>
<div>{[1, 3, 5, 15, 60].map(m => radiobox('createdWithin', String(m), m + 'm'))}</div>
</div>
</div>
)}
</>
);
};

View file

@ -71,7 +71,7 @@ const DEFAULT_PREFERENCES: ViewPreferences = {
version: 1,
appDetails: {
view: 'tree',
resourceFilter: ['kind:Deployment', 'kind:Service', 'kind:Pod', 'kind:StatefulSet', 'kind:Ingress', 'kind:ConfigMap', 'kind:Job', 'kind:DaemonSet', 'kind:Workflow'],
resourceFilter: [],
inlineDiff: false,
compactDiff: false,
resourceView: 'manifest',