Compare commits

..

3 Commits

Author SHA1 Message Date
dependabot[bot]
e972bfca78 chore(deps): bump yaml from 1.10.2 to 1.10.3 in /ui (#27015)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-26 10:42:29 -04:00
Jaewoo Choi
1b405ce2b5 feat(ui): search filter by target revision (#24038)
Signed-off-by: choejwoo <jaewoo45@gmail.com>
2026-03-26 12:17:33 +02:00
Jaewoo Choi
45b926d796 fix(ui): show clear-all button for annotation-only filters (#26937)
Signed-off-by: choejwoo <jaewoo45@gmail.com>
2026-03-26 12:08:13 +02:00
6 changed files with 125 additions and 51 deletions

View File

@@ -1,21 +1,21 @@
# Keycloak
Keycloak and Argo CD integration can be configured in two ways with Client authentication and with PKCE.
Keycloak and ArgoCD integration can be configured in two ways with Client authentication and with PKCE.
If you need to authenticate with __argo-cd command line__, you must choose PKCE way.
* [Keycloak and Argo CD with Client authentication](#keycloak-and-argocd-with-client-authentication)
* [Keycloak and Argo CD with PKCE](#keycloak-and-argocd-with-pkce)
* [Keycloak and ArgoCD with Client authentication](#keycloak-and-argocd-with-client-authentication)
* [Keycloak and ArgoCD with PKCE](#keycloak-and-argocd-with-pkce)
## Keycloak and Argo CD with Client authentication
## Keycloak and ArgoCD with Client authentication
These instructions will take you through the entire process of getting your Argo CD application to authenticate with Keycloak.
These instructions will take you through the entire process of getting your ArgoCD application authenticating with Keycloak.
Start by creating a client within Keycloak and configure Argo CD to use Keycloak for authentication, using groups set in Keycloak
You will create a client within Keycloak and configure ArgoCD to use Keycloak for authentication, using groups set in Keycloak
to determine privileges in Argo.
### Creating a new client in Keycloak
First, setup a new client.
First we need to setup a new client.
Start by logging into your keycloak server, select the realm you want to use (`master` by default)
and then go to __Clients__ and click the __Create client__ button at the top.
@@ -37,11 +37,11 @@ but it's not recommended in production).
Make sure to click __Save__.
There should be a tab called __Credentials__. You can copy the Client Secret that we'll use in our Argo CD configuration.
There should be a tab called __Credentials__. You can copy the Client Secret that we'll use in our ArgoCD configuration.
![Keycloak client secret](../../assets/keycloak-client-secret.png "Keycloak client secret")
### Configuring Argo CD OIDC
### Configuring ArgoCD OIDC
Let's start by storing the client secret you generated earlier in the argocd secret _argocd-secret_.
@@ -68,7 +68,7 @@ data:
clientID: argocd
clientSecret: $oidc.keycloak.clientSecret
refreshTokenThreshold: 2m
requestedScopes: ["openid", "profile", "email", "groups", "offline_access"]
requestedScopes: ["openid", "profile", "email", "groups"]
```
Make sure that:
@@ -80,18 +80,18 @@ Make sure that:
- __requestedScopes__ contains the _groups_ claim if you didn't add it to the Default scopes
- __refreshTokenThreshold__ is less than the client token lifetime. If this setting is not less than the token lifetime, a new token will be obtained for every request. Keycloak sets the client token lifetime to 5 minutes by default.
## Keycloak and Argo CD with PKCE
## Keycloak and ArgoCD with PKCE
These instructions will take you through the entire process of getting your Argo CD application authenticating with Keycloak.
These instructions will take you through the entire process of getting your ArgoCD application authenticating with Keycloak.
You will create a client within Keycloak and configure Argo CD to use Keycloak for authentication, using groups set in Keycloak
You will create a client within Keycloak and configure ArgoCD to use Keycloak for authentication, using groups set in Keycloak
to determine privileges in Argo.
You will also be able to authenticate using argo-cd command line.
### Creating a new client in Keycloak
First, setup a new client.
First we need to setup a new client.
Start by logging into your keycloak server, select the realm you want to use (`master` by default)
and then go to __Clients__ and click the __Create client__ button at the top.
@@ -119,7 +119,7 @@ Now go to a tab called __Advanced__, look for parameter named __Proof Key for Co
![Keycloak configure client Step 2](../../assets/keycloak-configure-client-pkce_2.png "Keycloak configure client Step 2")
Make sure to click __Save__.
### Configuring Argo CD OIDC
### Configuring ArgoCD OIDC
Now we can configure the config map and add the oidc configuration to enable our keycloak authentication.
You can use `$ kubectl edit configmap argocd-cm`.
@@ -138,7 +138,7 @@ data:
clientID: argocd
enablePKCEAuthentication: true
refreshTokenThreshold: 2m
requestedScopes: ["openid", "profile", "email", "groups", "offline_access"]
requestedScopes: ["openid", "profile", "email", "groups"]
```
Make sure that:
@@ -146,13 +146,13 @@ Make sure that:
- __issuer__ ends with the correct realm (in this example _master_)
- __issuer__ on Keycloak releases older than version 17 the URL must include /auth (in this example /auth/realms/master)
- __clientID__ is set to the Client ID you configured in Keycloak
- __enablePKCEAuthentication__ must be set to true to enable correct Argo CD behaviour with PKCE
- __enablePKCEAuthentication__ must be set to true to enable correct ArgoCD behaviour with PKCE
- __requestedScopes__ contains the _groups_ claim if you didn't add it to the Default scopes
- __refreshTokenThreshold__ is less than the client token lifetime. If this setting is not less than the token lifetime, a new token will be obtained for every request. Keycloak sets the client token lifetime to 5 minutes by default.
## Configuring the groups claim
In order for Argo CD to provide the groups the user is in we need to configure a groups claim that can be included in the authentication token.
In order for ArgoCD to provide the groups the user is in we need to configure a groups claim that can be included in the authentication token.
To do this we'll start by creating a new __Client Scope__ called _groups_.
@@ -174,7 +174,7 @@ Go back to the client we've created earlier and go to the Tab "Client Scopes".
Click on "Add client scope", choose the _groups_ scope and add it either to the __Default__ or to the __Optional__ Client Scope.
If you put it in the Optional
category you will need to make sure that Argo CD requests the scope in its OIDC configuration.
category you will need to make sure that ArgoCD requests the scope in its OIDC configuration.
Since we will always want group information, I recommend
using the Default category.
@@ -184,7 +184,7 @@ Create a group called _ArgoCDAdmins_ and have your current user join the group.
![Keycloak user group](../../assets/keycloak-user-group.png "Keycloak user group")
## Configuring Argo CD Policy
## Configuring ArgoCD Policy
Now that we have an authentication that provides groups we want to apply a policy to these groups.
We can modify the _argocd-rbac-cm_ ConfigMap using `$ kubectl edit configmap argocd-rbac-cm`.
@@ -205,7 +205,7 @@ In this example we give the role _role:admin_ to all users in the group _ArgoCDA
You can now login using our new Keycloak OIDC authentication:
![Keycloak Argo CD login](../../assets/keycloak-login.png "Keycloak Argo CD login")
![Keycloak ArgoCD login](../../assets/keycloak-login.png "Keycloak ArgoCD login")
If you have used PKCE method, you can also authenticate using command line:
```bash
@@ -219,7 +219,7 @@ Once done, you should see
![Authentication successful!](../../assets/keycloak-authentication-successful.png "Authentication successful!")
## Troubleshoot
If Argo CD auth returns 401 or when the login attempt leads to the loop, then restart the argocd-server pod.
If ArgoCD auth returns 401 or when the login attempt leads to the loop, then restart the argocd-server pod.
```
kubectl rollout restart deployment argocd-server -n argocd
```

View File

@@ -17,7 +17,7 @@ import {
import {AppsListPreferences, AppSetsListPreferences, services} from '../../../shared/services';
import {Filter, FiltersGroup} from '../filter/filter';
import {createMetadataSelector} from '../selectors';
import {ComparisonStatusIcon, getAppSetHealthStatus, HealthStatusIcon, getOperationStateTitle} from '../utils';
import {ComparisonStatusIcon, getAppAllSources, getAppSetHealthStatus, HealthStatusIcon, getOperationStateTitle} from '../utils';
import {formatClusterQueryParam} from '../../../shared/utils';
import {COLORS} from '../../../shared/components/colors';
@@ -27,6 +27,7 @@ export interface FilterResult {
health: boolean;
clusters: boolean;
namespaces: boolean;
targetRevision: boolean;
operation: boolean;
annotations: boolean;
favourite: boolean;
@@ -59,31 +60,41 @@ export function getAppFilterResults(applications: Application[], pref: AppsListP
const labelSelector = createMetadataSelector(pref.labelsFilter || []);
const annotationSelector = createMetadataSelector(pref.annotationsFilter || []);
return applications.map(app => ({
...app,
filterResult: {
sync: pref.syncFilter.length === 0 || pref.syncFilter.includes(app.status.sync.status),
autosync: pref.autoSyncFilter.length === 0 || pref.autoSyncFilter.includes(getAutoSyncStatus(app.spec.syncPolicy)),
health: pref.healthFilter.length === 0 || pref.healthFilter.includes(app.status.health.status),
namespaces: pref.namespacesFilter.length === 0 || pref.namespacesFilter.some(ns => app.spec.destination.namespace && minimatch(app.spec.destination.namespace, ns)),
favourite: !pref.showFavorites || (pref.favoritesAppList && pref.favoritesAppList.includes(app.metadata.name)),
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.spec.destination.server || name === app.spec.destination.name;
} else {
const inputMatch = filterString.match('^http.*$');
return (inputMatch && inputMatch[0] === app.spec.destination.server) || (app.spec.destination.name && minimatch(app.spec.destination.name, filterString));
}
}),
labels: pref.labelsFilter.length === 0 || labelSelector(app.metadata.labels),
annotations: pref.annotationsFilter.length === 0 || annotationSelector(app.metadata.annotations),
operation: pref.operationFilter.length === 0 || pref.operationFilter.includes(getOperationStateTitle(app))
}
}));
return applications.map(app => {
const targetRevisions = getAppAllSources(app)
.map(source => source.targetRevision)
.filter((item): item is string => !!item);
return {
...app,
filterResult: {
sync: pref.syncFilter.length === 0 || pref.syncFilter.includes(app.status.sync.status),
autosync: pref.autoSyncFilter.length === 0 || pref.autoSyncFilter.includes(getAutoSyncStatus(app.spec.syncPolicy)),
health: pref.healthFilter.length === 0 || pref.healthFilter.includes(app.status.health.status),
namespaces: pref.namespacesFilter.length === 0 || pref.namespacesFilter.some(ns => app.spec.destination.namespace && minimatch(app.spec.destination.namespace, ns)),
favourite: !pref.showFavorites || (pref.favoritesAppList && pref.favoritesAppList.includes(app.metadata.name)),
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.spec.destination.server || name === app.spec.destination.name;
} else {
const inputMatch = filterString.match('^http.*$');
return (
(inputMatch && inputMatch[0] === app.spec.destination.server) || (app.spec.destination.name && minimatch(app.spec.destination.name, filterString))
);
}
}),
targetRevision:
pref.targetRevisionFilter.length === 0 || pref.targetRevisionFilter.some(filter => targetRevisions.some(targetRevision => minimatch(targetRevision, filter))),
labels: pref.labelsFilter.length === 0 || labelSelector(app.metadata.labels),
annotations: pref.annotationsFilter.length === 0 || annotationSelector(app.metadata.annotations),
operation: pref.operationFilter.length === 0 || pref.operationFilter.includes(getOperationStateTitle(app))
}
};
});
}
export function getAppSetFilterResults(appSets: ApplicationSet[], pref: AppSetsListPreferences): ApplicationSetFilteredApp[] {
@@ -361,6 +372,26 @@ const NamespaceFilter = React.memo((props: AppFilterProps) => {
);
});
const TargetRevisionFilter = (props: AppFilterProps) => {
const targetRevisionOptions = React.useMemo(
() =>
optionsFrom(
Array.from(new Set(props.apps.flatMap(app => getAppAllSources(app).map(source => source.targetRevision)).filter((item): item is string => !!item))),
props.pref.targetRevisionFilter
),
[props.apps, props.pref.targetRevisionFilter]
);
return (
<Filter
label='TARGET REVISION'
selected={props.pref.targetRevisionFilter}
setSelected={s => props.onChange({...props.pref, targetRevisionFilter: s})}
field={true}
options={targetRevisionOptions}
/>
);
};
const FavoriteFilter = (props: {value: boolean; onChange: (showFavorites: boolean) => void}) => {
const onChange = (val: boolean) => {
props.onChange(val);
@@ -468,9 +499,11 @@ export const ApplicationsFilter = (props: AppFilterProps) => {
...(props.pref.healthFilter || []),
...(props.pref.operationFilter || []),
...(props.pref.labelsFilter || []),
...(props.pref.annotationsFilter || []),
...(props.pref.projectsFilter || []),
...(props.pref.clustersFilter || []),
...(props.pref.namespacesFilter || []),
...(props.pref.targetRevisionFilter || []),
...(props.pref.autoSyncFilter || []),
...(props.pref.showFavorites ? ['favorites'] : [])
];
@@ -492,6 +525,7 @@ export const ApplicationsFilter = (props: AppFilterProps) => {
<ProjectFilter {...props} />
<ClusterFilter {...props} />
<NamespaceFilter {...props} />
<TargetRevisionFilter {...props} />
<AutoSyncFilter {...props} collapsed={true} />
</FiltersGroup>
);

View File

@@ -160,6 +160,13 @@ const ViewPref = ({children}: {children: (pref: AppsListPreferences & {page: num
.split(',')
.filter(item => !!item);
}
if (params.get('targetRevision') != null) {
viewPref.targetRevisionFilter = params
.get('targetRevision')
.split(',')
.map(decodeURIComponent)
.filter(item => !!item);
}
if (params.get('cluster') != null) {
viewPref.clustersFilter = params
.get('cluster')
@@ -473,6 +480,7 @@ export const ApplicationsList = (props: RouteComponentProps<any> & {objectListKi
autoSync: newPref.autoSyncFilter.join(','),
health: newPref.healthFilter.join(','),
namespace: newPref.namespacesFilter.join(','),
targetRevision: newPref.targetRevisionFilter.map(encodeURIComponent).join(','),
cluster: newPref.clustersFilter.join(','),
labels: newPref.labelsFilter.map(encodeURIComponent).join(','),
annotations: newPref.annotationsFilter.map(encodeURIComponent).join(','),

View File

@@ -1479,6 +1479,34 @@ export function getAppDrySource(app?: appModels.Application): appModels.Applicat
return {repoURL, targetRevision, path};
}
// getAppAllSources gets all app sources as an array. For single source apps, returns [source].
// For multi-source apps, returns the sources array. For sourceHydrator apps, returns a single synthesized source.
export function getAppAllSources(app?: appModels.Application): appModels.ApplicationSource[] {
if (!app) {
return [];
}
if (app.spec.sourceHydrator) {
return [
{
repoURL: app.spec.sourceHydrator.drySource.repoURL,
targetRevision: app.spec.sourceHydrator.syncSource.targetBranch,
path: app.spec.sourceHydrator.syncSource.path
} as appModels.ApplicationSource
];
}
if (app.spec.sources && app.spec.sources.length > 0) {
return app.spec.sources;
}
if (app.spec.source) {
return [app.spec.source];
}
return [];
}
// getAppDefaultSyncRevision gets the first app revisions from `status.sync.revisions` or, if that list is missing or empty, the `revision`
// field.
export function getAppDefaultSyncRevision(app?: appModels.Application) {

View File

@@ -91,6 +91,7 @@ export class AppsListPreferences extends AbstractAppsListPreferences {
pref.clustersFilter = [];
pref.namespacesFilter = [];
pref.targetRevisionFilter = [];
pref.projectsFilter = [];
pref.syncFilter = [];
pref.autoSyncFilter = [];
@@ -102,6 +103,7 @@ export class AppsListPreferences extends AbstractAppsListPreferences {
public autoSyncFilter: string[];
public namespacesFilter: string[];
public clustersFilter: string[];
public targetRevisionFilter: string[];
public operationFilter: string[];
}
@@ -156,6 +158,7 @@ const DEFAULT_PREFERENCES: ViewPreferences = {
annotationsFilter: new Array<string>(),
projectsFilter: new Array<string>(),
namespacesFilter: new Array<string>(),
targetRevisionFilter: new Array<string>(),
clustersFilter: new Array<string>(),
syncFilter: new Array<string>(),
autoSyncFilter: new Array<string>(),
@@ -228,6 +231,7 @@ export class ViewPreferencesService {
appList.annotationsFilter = appList.annotationsFilter || [];
appList.projectsFilter = appList.projectsFilter || [];
appList.namespacesFilter = appList.namespacesFilter || [];
appList.targetRevisionFilter = appList.targetRevisionFilter || [];
appList.clustersFilter = appList.clustersFilter || [];
appList.syncFilter = appList.syncFilter || [];
appList.autoSyncFilter = appList.autoSyncFilter || [];

View File

@@ -9705,9 +9705,9 @@ yaml-ast-parser@0.0.43:
integrity sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==
yaml@^1.10.0:
version "1.10.2"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
version "1.10.3"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.3.tgz#76e407ed95c42684fb8e14641e5de62fe65bbcb3"
integrity sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==
yargs-parser@^20.2.2:
version "20.2.9"