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(ui): Introduce Resources List #21468

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
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
8 changes: 8 additions & 0 deletions ui/src/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {Helmet} from 'react-helmet';
import {Redirect, Route, RouteComponentProps, Router, Switch} from 'react-router';
import {Subscription} from 'rxjs';
import applications from './applications';
import resources from './resources';
import help from './help';
import login from './login';
import settings from './settings';
Expand Down Expand Up @@ -34,6 +35,7 @@ type Routes = {[path: string]: {component: React.ComponentType<RouteComponentPro
const routes: Routes = {
'/login': {component: login.component as any, noLayout: true},
'/applications': {component: applications.component},
'/resources': {component: resources.component},
'/settings': {component: settings.component},
'/user-info': {component: userInfo.component},
'/help': {component: help.component},
Expand All @@ -54,6 +56,12 @@ const navItems: NavItem[] = [
path: '/applications',
iconClassName: 'argo-icon argo-icon-application'
},
{
title: 'Resources',
tooltip: 'Display all resource argocd managed.',
path: '/resources',
iconClassName: 'argo-icon argo-icon-catalog'
},
{
title: 'Settings',
tooltip: 'Manage your repositories, projects, settings',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -372,12 +372,7 @@ export const ApplicationsList = (props: RouteComponentProps<{}>) => {
{ctx => (
<ViewPref>
{pref => (
<Page
key={pref.view}
title={getPageTitle(pref.view)}
useTitleOnly={true}
toolbar={{breadcrumbs: [{title: 'Applications', path: '/applications'}]}}
hideAuth={true}>
<Page title={getPageTitle(pref.view)} useTitleOnly={true} toolbar={{breadcrumbs: [{title: 'Applications', path: '/applications'}]}} hideAuth={true}>
<DataLoader
input={pref.projectsFilter?.join(',')}
ref={loaderRef}
Expand Down Expand Up @@ -407,12 +402,14 @@ export const ApplicationsList = (props: RouteComponentProps<{}>) => {
return (
<React.Fragment>
<FlexTopBar
key={`toolbar-${healthBarPrefs.showHealthStatusBar}-${pref.view}`}
toolbar={{
tools: (
<React.Fragment key='app-list-tools'>
<Query>{q => <SearchBar content={q.get('search')} apps={applications} ctx={ctx} />}</Query>
<Tooltip content='Toggle Health Status Bar'>
<button
key={`healthBarPrefs.showHealthStatusBar-${healthBarPrefs.showHealthStatusBar}`}
className={`applications-list__accordion argo-button argo-button--base${
healthBarPrefs.showHealthStatusBar ? '-o' : ''
}`}
Expand Down
10 changes: 10 additions & 0 deletions ui/src/app/resources/components/resources-container.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as React from 'react';
import {Route, RouteComponentProps, Switch} from 'react-router';

import {ResourcesList} from './resources-list/resources-list';

export const ResourceContainer = (props: RouteComponentProps<any>) => (
<Switch>
<Route exact={true} path={`${props.match.path}`} component={ResourcesList} />
</Switch>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
@import 'node_modules/foundation-sites/scss/util/util';
@import '../../../shared/config.scss';

.flex-top-bar {
position: fixed;
right: 0;
z-index: 5;
padding: 0 15px;
left: $sidebar-width;
align-items: center;
flex-wrap: wrap;


&__tools {
display: flex;
flex-grow: 1;
padding-top: 8px;
padding-bottom: 5px;
justify-content: space-between;
align-items: center;
@include breakpoint(medium down) {
flex-wrap: wrap;
}
}

&__padder {
height: 50px;
@include breakpoint(medium down) {
height: 150px;
}
}
}
213 changes: 213 additions & 0 deletions ui/src/app/resources/components/resources-list/resources-filter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import {useData} from 'argo-ui/v2';
import * as minimatch from 'minimatch';
import * as React from 'react';
import {Cluster, HealthStatusCode, HealthStatuses, Resource, SyncStatusCode, SyncStatuses} from '../../../shared/models';
import {ResourcesListPreferences, services} from '../../../shared/services';
import {Filter, FiltersGroup} from '../../../applications/components/filter/filter';
import {ComparisonStatusIcon, HealthStatusIcon} from '../../../applications/components/utils';

export interface FilterResult {
sync: boolean;
health: boolean;
namespaces: boolean;
clusters: boolean;
}

export interface FilteredResource extends Resource {
filterResult: FilterResult;
}

export function getFilterResults(resources: Resource[], pref: ResourcesListPreferences): FilteredResource[] {
return resources.map(app => ({
...app,
filterResult: {
sync: pref.syncFilter.length === 0 || pref.syncFilter.includes(app.status),
health: pref.healthFilter.length === 0 || pref.healthFilter.includes(app.health?.status),
namespaces: pref.namespacesFilter.length === 0 || pref.namespacesFilter.some(ns => app.namespace && minimatch(app.namespace, ns)),
clusters:
pref.clustersFilter.length === 0 ||
pref.clustersFilter.some(filterString => {
const match = filterString.match('^(.*) [(](http.*)[)]$');
if (match?.length === 3) {
const [, name, url] = match;
return url === app.clusterServer || name === app.clusterName;
} else {
const inputMatch = filterString.match('^http.*$');
return (inputMatch && inputMatch[0] === app.clusterServer) || (app.clusterName && minimatch(app.clusterName, filterString));
}
}),
apiGroup: pref.apiGroupFilter.length === 0 || pref.apiGroupFilter.includes(app.group),
kind: pref.kindFilter.length === 0 || pref.kindFilter.includes(app.kind)
}
}));
}

const optionsFrom = (options: string[], filter: string[]) => {
return options
.filter(s => filter.indexOf(s) === -1)
.map(item => {
return {label: item};
});
};

interface AppFilterProps {
apps: FilteredResource[];
pref: ResourcesListPreferences;
onChange: (newPrefs: ResourcesListPreferences) => void;
children?: React.ReactNode;
collapsed?: boolean;
}

const getCounts = (apps: FilteredResource[], filterType: keyof FilterResult, filter: (app: Resource) => string, init?: string[]) => {
const map = new Map<string, number>();
if (init) {
init.forEach(key => map.set(key, 0));
}
// filter out all apps that does not match other filters and ignore this filter result
apps.filter(app => filter(app) && Object.keys(app.filterResult).every((key: keyof FilterResult) => key === filterType || app.filterResult[key])).forEach(app =>
map.set(filter(app), (map.get(filter(app)) || 0) + 1)
);
return map;
};

const getOptions = (apps: FilteredResource[], filterType: keyof FilterResult, filter: (app: Resource) => string, keys: string[], getIcon?: (k: string) => React.ReactNode) => {
const counts = getCounts(apps, filterType, filter, keys);
return keys.map(k => {
return {
label: k,
icon: getIcon && getIcon(k),
count: counts.get(k)
};
});
};

const SyncFilter = (props: AppFilterProps) => (
<Filter
label='SYNC STATUS'
selected={props.pref.syncFilter}
setSelected={s => props.onChange({...props.pref, syncFilter: s})}
options={getOptions(
props.apps,
'sync',
app => app.status,
Object.keys(SyncStatuses),
s => (
<ComparisonStatusIcon status={s as SyncStatusCode} noSpin={true} />
)
)}
/>
);

const HealthFilter = (props: AppFilterProps) => (
<Filter
label='HEALTH STATUS'
selected={props.pref.healthFilter}
setSelected={s => props.onChange({...props.pref, healthFilter: s})}
options={getOptions(
props.apps,
'health',
app => app.health?.status || HealthStatuses.Unknown,
Object.keys(HealthStatuses),
s => (
<HealthStatusIcon state={{status: s as HealthStatusCode, message: ''}} noSpin={true} />
)
)}
/>
);

const ProjectFilter = (props: AppFilterProps) => {
const [projects, loading, error] = useData(
() => services.projects.list('items.metadata.name'),
null,
() => null
);
const projectOptions = (projects || []).map(proj => {
return {label: proj.metadata.name};
});
return (
<Filter
label='PROJECTS'
selected={props.pref.projectsFilter}
setSelected={s => props.onChange({...props.pref, projectsFilter: s})}
field={true}
options={projectOptions}
error={error.state}
retry={error.retry}
loading={loading}
/>
);
};

const ClusterFilter = (props: AppFilterProps) => {
const getClusterDetail = (dest: Resource, clusterList: Cluster[]): string => {
const cluster = (clusterList || []).find(target => target.name === dest.clusterName || target.server === dest.clusterServer);
if (!cluster) {
return dest.clusterServer || dest.clusterName;
}
if (cluster.name === cluster.server) {
return cluster.name;
}
return `${cluster.name} (${cluster.server})`;
};

const [clusters, loading, error] = useData(() => services.clusters.list());
const clusterOptions = optionsFrom(Array.from(new Set(props.apps.map(app => getClusterDetail(app, clusters)).filter(item => !!item))), props.pref.clustersFilter);

return (
<Filter
label='CLUSTERS'
selected={props.pref.clustersFilter}
setSelected={s => props.onChange({...props.pref, clustersFilter: s})}
field={true}
options={clusterOptions}
error={error.state}
retry={error.retry}
loading={loading}
/>
);
};

const NamespaceFilter = (props: AppFilterProps) => {
const namespaceOptions = optionsFrom(Array.from(new Set(props.apps.map(app => app.namespace).filter(item => !!item))), props.pref.namespacesFilter);
return (
<Filter
label='NAMESPACES'
selected={props.pref.namespacesFilter}
setSelected={s => props.onChange({...props.pref, namespacesFilter: s})}
field={true}
options={namespaceOptions}
/>
);
};

const ApiGroupFilter = (props: AppFilterProps) => {
const apiGroupOptions = optionsFrom(Array.from(new Set(props.apps.map(app => app.group).filter(item => !!item))), props.pref.apiGroupFilter);
return (
<Filter
label='API GROUPS'
selected={props.pref.apiGroupFilter}
setSelected={s => props.onChange({...props.pref, apiGroupFilter: s})}
field={true}
options={apiGroupOptions}
/>
);
};

const KindFilter = (props: AppFilterProps) => {
const kindOptions = optionsFrom(Array.from(new Set(props.apps.map(app => app.kind).filter(item => !!item))), props.pref.kindFilter);
return <Filter label='KINDS' selected={props.pref.kindFilter} setSelected={s => props.onChange({...props.pref, kindFilter: s})} field={true} options={kindOptions} />;
};

export const ResourcesFilter = (props: AppFilterProps) => {
return (
<FiltersGroup title='Resources filters' content={props.children} collapsed={props.collapsed}>
<SyncFilter {...props} />
<HealthFilter {...props} />
<ProjectFilter {...props} />
<ClusterFilter {...props} />
<NamespaceFilter {...props} />
<ApiGroupFilter {...props} />
<KindFilter {...props} />
</FiltersGroup>
);
};
Loading
Loading