Wire in pagination to LabeledSelect and use for pods, secrets and configmaps (#10786)

* Changes for new design
- New visuals
- Pagination controls --> load more
- finished testing of label select with pagination off

# Conflicts:
#	shell/edit/provisioning.cattle.io.cluster/__tests__/Basics.tests.ts

* Changes following review

* Update Node list to support server-side pagination
- Setup pagination headers for the node type
- Define a pattern for fetching custom list secondary resources
- Major improvements to the way pagination settings are defined and created
- Lots of docs improvements
- Handle calling fetch again once fetch is in progress (nuxt caches running request)
- Validate filter fields (not all are supported by the vai cache
- General pagination fixes

* Lint / test / fixes

* Improvements to configmap e2e test & Improve pagination disabled

* Beef up validation

* Fix missing name column in non-server-side paginated node list

* Fix PR automation actions
- fix syntax
- catch scenario where a pr has no fixed issue

> There's duplication between files, see https://github.com/rancher/dashboard/pull/10534

* CI bump

* Fixes post merge

* Wire in 2.9.0 settings for server-side pagination
- Everything is gated on `on-disk-steve-cache` feature flag
  - There's a backend in progress item to resolve a `revision` issue, until then disable watching a resource given it
- Global Settings - Performance
  - Added new setting to enable server side pagination
  - this is incompatible with two other performance settings

* Integrate pagination with configmaps in cis clusterscanbenchmark edit form
Also
- improved labeled select pagination
- gate label select pagination functinality on steve cache being enabled

* TODOs and TEST

* Paginate Secret selection for logging providers
- Allow `None` option in Paginationed LabelSelect
- Optionally classify pagination response

* WIP

* fixes arfter merge

* Don't suggest container names, not practical
- previously all pods were fetched... and we scrapped all container names from them
- this is a scaling nightmare, user now must just enter the name/s to match

* Avoid findAll secrets in SimpleSecretSelector

* tidying up

* Move LabeledSelect/index.vue back to LabeledSelect.vue to not break extensions

* changes after self review... 1

* changes after self review... 2

* changes after self review... 3

* Link new paginated label select with pagination setting

* Work around failing kubewarden unit tests in check-plugins gate

* Fix backup.spec e2e test

* Create a convienence wrapper called ResourceLabelSelector that hides most of the complexity

* fix unit test
This commit is contained in:
Richard Cox 2024-05-30 10:49:29 +01:00 committed by GitHub
parent 67dd70e5b2
commit 542ebd4f40
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 972 additions and 138 deletions

View File

@ -41,7 +41,7 @@ describe('Charts', { tags: ['@charts', '@adminUser'] }, () => {
installPage.nextPage();
cy.wait('@storageClasses', { timeout: 10000 }).its('response.statusCode').should('eq', 200);
cy.wait('@persistentVolumes', { timeout: 10000 }).its('response.statusCode').should('eq', 200);
cy.wait('@secrets', { timeout: 10000 }).its('response.statusCode').should('eq', 200);
installPage.waitForPage('repo-type=cluster&repo=rancher-charts&chart=rancher-backup');
// Select the 'Use an existing storage class' option

View File

@ -112,7 +112,8 @@
--dropdown-hover-bg : var(--link);
--dropdown-disabled-bg : #{$disabled};
--dropdown-disabled-text : #{$disabled};
--dropdown-highlight-text : var(--link);
--dropdown-group-text : #{$disabled};
--dropdown-highlight-text : var(--link);
--input-text : #{$lightest};
--input-label : #{$lighter};

View File

@ -376,6 +376,7 @@ BODY, .theme-light {
--dropdown-hover-bg : var(--link);
--dropdown-disabled-text : var(--muted);
--dropdown-disabled-bg : #{$disabled};
--dropdown-group-text : #{$darker};
--dropdown-highlight-text : var(--link);
--card-badge-text : #ffffff;

View File

@ -94,6 +94,11 @@
hr {
cursor: default;
}
.vs__option-kind-group {
color: var(--dropdown-group-text);
cursor: default;
}
}
&.vs__dropdown-option--selected {

View File

@ -2874,6 +2874,13 @@ labels:
annotations:
title: Annotations
labelSelect:
noOptions:
empty: No options
noMatch: No matching options
pagination:
counts: '{count} / {totalCount} items'
more: Load More...
landing:
clusters:
title: Clusters
@ -2940,6 +2947,7 @@ logging:
include: Limit to specific container names
exclude: Exclude specific container names
placeholder: "Default: Any container"
enter: Enter container name to select
namespaces:
title:
include: Limit to specific namespaces
@ -7341,7 +7349,7 @@ performance:
checkboxLabel: Enable Server-side Pagination
applicable: "This applies to the following resource types"
incompatibleDescription: "Server-side Pagination is incomaptible with Manual Refresh and Incremental Loading. Enabling this will disable them."
featureFlag: The <a href="{ffUrl}">Feature Flag</a> `on-disk-steve-cache` must be enabled to use this feature
featureFlag: The&nbsp;<a href="{ffUrl}">Feature Flag</a>&nbsp;`on-disk-steve-cache` must be enabled to use this feature
resources:
generic: most resources in the cluster's 'More Resources' section
all: All Resources

View File

@ -9,8 +9,14 @@ const localVue = createLocalVue();
localVue.use(Vuex);
describe('rancher-backup: S3', () => {
const mockStore = { getters: { 'i18n/t': (text: string) => text, t: (text: string) => text } };
const wrapper = mount(S3, { mocks: { $store: mockStore } });
const mockStore = {
getters: {
'i18n/t': (text: string) => text,
t: (text: string) => text,
'cluster/all': () => [],
}
};
const wrapper = mount(S3, { mocks: { $store: mockStore, $fetchState: { pending: false } } });
it('should emit invalid when form is not filled', () => {
expect(wrapper.emitted('valid')).toHaveLength(1);

View File

@ -2,15 +2,16 @@
import { LabeledInput } from '@components/Form/LabeledInput';
import { Checkbox } from '@components/Form/Checkbox';
import FileSelector from '@shell/components/form/FileSelector';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import ResourceLabeledSelect from '@shell/components/form/ResourceLabeledSelect';
import { mapGetters } from 'vuex';
import { SECRET } from '@shell/config/types';
export default {
components: {
LabeledInput,
Checkbox,
FileSelector,
LabeledSelect,
ResourceLabeledSelect,
},
props: {
@ -25,10 +26,10 @@ export default {
default: 'create'
},
secrets: {
type: Array,
default: () => []
}
},
data() {
return { SECRET };
},
mounted() {
@ -44,6 +45,7 @@ export default {
this.$emit('valid', this.valid);
}
},
computed: {
credentialSecret: {
get() {
@ -75,7 +77,7 @@ export default {
// eslint-disable-next-line no-console
console.warn(e);
}
}
},
},
created() {
@ -92,13 +94,13 @@ export default {
<div>
<div class="row mb-10">
<div class="col span-6">
<LabeledSelect
<ResourceLabeledSelect
v-model="credentialSecret"
:get-option-label="opt=>opt.metadata.name || ''"
option-key="id"
:mode="mode"
:options="secrets"
:label="t('backupRestoreOperator.s3.credentialSecretName')"
:resource-type="SECRET"
/>
</div>
<div class="col span-6">

View File

@ -7,7 +7,7 @@ import { LabeledInput } from '@components/Form/LabeledInput';
import { Banner } from '@components/Banner';
import { get } from '@shell/utils/object';
import { allHash } from '@shell/utils/promise';
import { STORAGE_CLASS, SECRET, PV } from '@shell/config/types';
import { STORAGE_CLASS, PV } from '@shell/config/types';
import { mapGetters } from 'vuex';
import { STORAGE } from '@shell/config/labels-annotations';
@ -41,10 +41,8 @@ export default {
const hash = await allHash({
storageClasses: this.$store.dispatch('cluster/findAll', { type: STORAGE_CLASS }),
persistentVolumes: this.$store.dispatch('cluster/findAll', { type: PV }),
secrets: this.$store.dispatch('cluster/findAll', { type: SECRET }),
});
this.secrets = hash.secrets;
this.storageClasses = hash.storageClasses;
this.persistentVolumes = hash.persistentVolumes;
@ -89,6 +87,12 @@ export default {
watch: {
storageSource(neu) {
if (!this.value.persistence) {
this.value.persistence = {};
}
if (!this.value.s3) {
this.value.s3 = {};
}
switch (neu) {
case 'pickSC':
this.value.persistence.enabled = true;

View File

@ -4,7 +4,7 @@ import { _VIEW, _CREATE } from '@shell/config/query-params';
import LabeledSelect from '@shell/components/form/LabeledSelect.vue';
import Checkbox from '@components/Form/Checkbox/Checkbox.vue';
import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue';
import { PSADimension, PSAMode } from '@shell/types/pod-security-admission';
import { PSADimension, PSAMode } from '@shell/types/resources/pod-security-admission';
import {
PSADefaultLevel,
PSADefaultVersion, PSADimensions, PSALevels, PSAModes

View File

@ -5,12 +5,16 @@ import { get } from '@shell/utils/object';
import { LabeledTooltip } from '@components/LabeledTooltip';
import VueSelectOverrides from '@shell/mixins/vue-select-overrides';
import { onClickOption, calculatePosition } from '@shell/utils/select';
import LabeledSelectPagination from '@shell/components/form/labeled-select-utils/labeled-select-pagination';
import { LABEL_SELECT_NOT_OPTION_KINDS } from '@shell/types/components/labeledSelect';
// In theory this would be nicer as LabeledSelect/index.vue, however that would break a lot of places where we import this (which includes extensions)
export default {
name: 'LabeledSelect',
components: { LabeledTooltip },
mixins: [CompactInput, LabeledFormElement, VueSelectOverrides],
mixins: [CompactInput, LabeledFormElement, VueSelectOverrides, LabeledSelectPagination],
props: {
appendToBody: {
@ -66,7 +70,7 @@ export default {
selectable: {
default: (opt) => {
if ( opt ) {
if ( opt.disabled || opt.kind === 'group' || opt.kind === 'divider' || opt.loading ) {
if ( opt.disabled || LABEL_SELECT_NOT_OPTION_KINDS.includes(opt.kind) || opt.loading ) {
return false;
}
}
@ -88,13 +92,17 @@ export default {
type: [String, Object, Number, Array, Boolean]
},
options: {
type: Array,
required: true
type: Array,
default: () => ([])
},
closeOnSelect: {
type: Boolean,
default: true
},
noOptionsLabelKey: {
type: String,
default: 'labelSelect.noOptions.empty'
}
},
data() {
@ -108,6 +116,16 @@ export default {
hasLabel() {
return this.isCompact ? false : !!this.label || !!this.labelKey || !!this.$slots.label;
},
hasGroupIcon() {
// Required for option.icon. Note that we only apply if paginating as well (there might be 2 x performance issues with 2k entries. one to iterate through this list, the other with conditional class per entry in dom)
return this.canPaginate ? !!this._options.find((o) => o.kind === 'group' && !!o.icon) : false;
},
_options() {
// If we're paginated show the page as provided by `paginate`. See label-select-pagination mixin
return this.canPaginate ? this.page : this.options;
}
},
methods: {
@ -198,8 +216,12 @@ export default {
},
onSearch(newSearchString) {
if (newSearchString) {
this.dropdownShouldOpen(this.$refs['select-input'], true);
if (this.canPaginate) {
this.setPaginationFilter(newSearchString);
} else {
if (newSearchString) {
this.dropdownShouldOpen(this.$refs['select-input'], true);
}
}
},
@ -255,16 +277,17 @@ export default {
class="inline"
:append-to-body="appendToBody"
:calculate-position="positionDropdown"
:class="{ 'no-label': !(label || '').length }"
:class="{ 'no-label': !(label || '').length}"
:clearable="clearable"
:disabled="isView || disabled || loading"
:get-option-key="getOptionKey"
:get-option-label="(opt) => getOptionLabel(opt)"
:label="optionLabel"
:options="options"
:options="_options"
:map-keydown="mappedKeys"
:placeholder="placeholder"
:reduce="(x) => reduce(x)"
:filterable="isFilterable"
:searchable="isSearchable"
:selectable="selectable"
:value="value != null && !loading ? value : ''"
@ -280,6 +303,11 @@ export default {
<template #option="option">
<template v-if="option.kind === 'group'">
<div class="vs__option-kind-group">
<i
v-if="option.icon"
class="icon"
:class="{ [option.icon]: true}"
/>
<b>{{ getOptionLabel(option) }}</b>
<div v-if="option.badge">
{{ option.badge }}
@ -296,6 +324,8 @@ export default {
</template>
<div
v-else
class="vs__option-kind"
:class="{ 'has-icon' : hasGroupIcon}"
@mousedown="(e) => onClickOption(option, e)"
>
{{ getOptionLabel(option) }}
@ -316,6 +346,45 @@ export default {
v-bind="scope"
/>
</template>
<div
v-if="canPaginate && totalResults"
slot="list-footer"
class="pagination-slot"
>
<div class="load-more">
<i
v-if="paginating"
class="icon icon-spinner icon-spin"
/>
<div v-else>
<a
v-if="canLoadMore"
@click="loadMore"
> {{ t('labelSelect.pagination.more') }}</a>
</div>
</div>
<div class="count">
{{ optionCounts }}
</div>
</div>
<template #no-options="{ search }">
<div class="no-options-slot">
<div
v-if="paginating"
class="paginating"
>
<i class="icon icon-spinner icon-spin" />
</div>
<template v-else-if="search">
{{ t('labelSelect.noOptions.noMatch') }}
</template>
<template v-else>
{{ t(noOptionsLabelKey) }}
</template>
</div>
</template>
</v-select>
<i
v-if="loading"
@ -493,21 +562,73 @@ export default {
}
}
// Styling for option group badge
.vs__dropdown-menu .vs__dropdown-option .vs__option-kind-group {
display: flex;
> b {
flex: 1;
$icon-size: 18px;
// This represents the drop down area. Note - it might be attached to body and NOT the parent label select div
.vs__dropdown-menu {
// Styling for individual options
.vs__dropdown-option .vs__option-kind {
&-group {
display: flex;
align-items: center;
i { // icon
width: $icon-size;
}
> b { // group label
flex: 1;
}
> div { // badge
background-color: var(--primary);
border-radius: 4px;
color: var(--primary-text);
font-size: 12px;
height: 18px;
line-height: 18px;
margin-top: 1px;
padding: 0 10px;
}
}
&.has-icon {
padding-left: $icon-size;
}
}
> div {
background-color: var(--primary);
border-radius: 4px;
color: var(--primary-text);
font-size: 12px;
height: 18px;
line-height: 18px;
margin-top: 1px;
padding: 0 10px;
&.has-icon .vs__option-kind div{
padding-left: $icon-size;
}
.pagination-slot {
display: flex;
align-items: center;
justify-content: center;
position: relative;
margin-top: 5px;
.load-more {
display: flex;
align-items: center;
height: 19px;
a {
cursor: pointer;
}
}
.count {
position: absolute;
right: 10px;
}
}
.no-options-slot .paginating {
display: flex;
align-items: center;
justify-content: center;
}
}

View File

@ -0,0 +1,188 @@
<script lang="ts">
import { PropType, defineComponent } from 'vue';
import LabeledSelect from '@shell/components/form/LabeledSelect.vue';
import { PaginationParamFilter } from '@shell/types/store/pagination.types';
import { labelSelectPaginationFunction, LabelSelectPaginationFunctionOptions } from '@shell/components/form/labeled-select-utils/labeled-select.utils';
import paginationUtils from '@shell/utils/pagination-utils';
import { LabelSelectPaginateFn, LabelSelectPaginateFnOptions, LabelSelectPaginateFnResponse } from '~/shell/types/components/labeledSelect';
type PaginateTypeOverridesFn = (opts: LabelSelectPaginationFunctionOptions) => LabelSelectPaginationFunctionOptions;
interface SharedSettings {
/**
* Provide specific LabelSelect options for this mode (paginated / not paginated)
*/
labelSelectOptions?: { [key: string]: any },
/**
* Map the resources shown in LabelSelect
*/
mapResult?: (resources: any[]) => any[]
}
/**
* Settings to use when the LabelSelect is paginating
*/
export interface ResourceLabeledSelectPaginateSettings extends SharedSettings {
/**
* Override the convience function which fetches a page of results
*/
overrideRequest?: LabelSelectPaginateFn,
/**
* Override the default settings used in the convience function to fetch a page of results
*/
requestSettings?: PaginateTypeOverridesFn,
}
/**
* Settings to use when the LabelSelect is fetching all resources (not paginating)
*/
export type ResourceLabeledSelectSettings = SharedSettings
/**
* Force a specific mode
*/
export enum RESOURCE_LABEL_SELECT_MODE {
/**
* Fetch all resources
*/
ALL_RESOURCES = 'ALL', // eslint-disable-line no-unused-vars
/**
* Determine if all resources are fetched given system settings
*/
DYNAMIC = 'DYNAMIC', // eslint-disable-line no-unused-vars
}
/**
* Convience wrapper around the LabelSelect component to support pagination
*
* Handles
*
* 1) Conditionally enabling the pagination feature given system settings
* 2) Helper function to fetch the pagination result
*
* A number of ways can be provided to override the convienences (see props)
*/
export default defineComponent({
name: 'ResourceLabeledSelect',
components: { LabeledSelect },
props: {
/**
* Resource to show
*/
resourceType: {
type: String,
required: true
},
inStore: {
type: String,
default: 'cluster',
},
/**
* Determine if pagination is used via settings (DYNAMIC) or hardcode off
*/
paginateMode: {
type: String as PropType<RESOURCE_LABEL_SELECT_MODE>,
default: RESOURCE_LABEL_SELECT_MODE.DYNAMIC,
},
/**
* Specific settings to use when we're showing all results
*/
allResourcesSettings: {
type: Object as PropType<ResourceLabeledSelectSettings>,
default: null,
},
/**
* Specific settings to use when we're showing paginated results
*/
paginatedResourceSettings: {
type: Object as PropType<ResourceLabeledSelectPaginateSettings>,
default: null,
},
},
data() {
return { paginate: false };
},
async fetch() {
switch (this.paginateMode) {
case RESOURCE_LABEL_SELECT_MODE.ALL_RESOURCES:
this.paginate = false;
break;
case RESOURCE_LABEL_SELECT_MODE.DYNAMIC:
this.paginate = paginationUtils.isEnabled({ rootGetters: this.$store.getters }, { store: this.inStore, resource: { id: this.resourceType } });
break;
}
if (!this.paginate) {
await this.$store.dispatch(`${ this.inStore }/findAll`, { type: this.resourceType });
}
},
computed: {
labelSelectAttributes() {
return this.paginate ? {
...this.$attrs,
...this.paginatedResourceSettings?.labelSelectOptions || {}
} : {
...this.$attrs,
...this.allResourcesSettings?.labelSelectOptions || {}
};
},
allOfType() {
if (this.$fetchState.pending || this.paginate) {
return [];
}
const all = this.$store.getters[`${ this.inStore }/all`](this.resourceType);
return this.allResourcesSettings?.mapResult ? this.allResourcesSettings.mapResult(all) : all;
}
},
methods: {
/**
* Typeof LabelSelectPaginateFn
*/
async paginateType(opts: LabelSelectPaginateFnOptions): Promise<LabelSelectPaginateFnResponse> {
if (this.paginatedResourceSettings?.overrideRequest) {
return await this.paginatedResourceSettings.overrideRequest(opts);
}
const { filter } = opts;
const filters = !!filter ? [PaginationParamFilter.createSingleField({ field: 'metadata.name', value: filter })] : [];
const defaultOptions: LabelSelectPaginationFunctionOptions = {
opts,
filters,
type: this.resourceType,
ctx: { getters: this.$store.getters, dispatch: this.$store.dispatch },
sort: [{ asc: true, field: 'metadata.name' }],
};
const options = this.paginatedResourceSettings?.requestSettings ? this.paginatedResourceSettings.requestSettings(defaultOptions) : defaultOptions;
const res = await labelSelectPaginationFunction(options);
return this.paginatedResourceSettings?.mapResult ? {
...res,
page: this.paginatedResourceSettings.mapResult(res.page)
} : res;
},
},
});
</script>
<template>
<LabeledSelect
v-bind="labelSelectAttributes"
:loading="$fetchState.pending"
:options="allOfType"
:paginate="paginateType"
v-on="$listeners"
/>
</template>

View File

@ -1,14 +1,16 @@
<script>
import LabeledSelect from '@shell/components/form/LabeledSelect';
import ResourceLabeledSelect from '@shell/components/form/ResourceLabeledSelect';
import { SECRET } from '@shell/config/types';
import { _EDIT, _VIEW } from '@shell/config/query-params';
import { SECRET_TYPES as TYPES } from '@shell/config/secret';
import sortBy from 'lodash/sortBy';
import { PaginationParamFilter } from '@shell/types/store/pagination.types';
import { LABEL_SELECT_KINDS } from '@shell/types/components/labeledSelect';
const NONE = '__[[NONE]]__';
export default {
components: { LabeledSelect },
components: { LabeledSelect, ResourceLabeledSelect },
props: {
value: {
@ -62,6 +64,33 @@ export default {
}
},
data() {
return {
secrets: null,
SECRET,
allSecretsSettings: {
mapResult: (secrets) => {
const allSecretsInNamespace = secrets.filter((secret) => this.types.includes(secret._type) && secret.namespace === this.namespace);
const mappedSecrets = this.mapSecrets(allSecretsInNamespace.sort((a, b) => a.name.localeCompare(b.name)));
this.secrets = allSecretsInNamespace; // We need the key from the selected secret
return mappedSecrets;
}
},
paginateSecretsSetting: {
requestSettings: this.paginatePageOptions,
mapResult: (secrets) => {
const mappedSecrets = this.mapSecrets(secrets);
this.secrets = secrets; // We need the key from the selected secret. When paginating we won't touch the store, so just pass back here
return mappedSecrets;
}
}
};
},
computed: {
name: {
get() {
@ -89,36 +118,77 @@ export default {
this.$emit('input', { [this.mountKey]: { secretKeyRef: { [this.nameKey]: this.name, [this.keyKey]: key } } });
}
},
secrets() {
const allSecrets = this.$store.getters[`${ this.inStore }/all`](SECRET);
return allSecrets
.filter((secret) => this.types.includes(secret._type) && secret.namespace === this.namespace);
},
secretNames() {
const mappedSecrets = this.secrets.map((secret) => ({
label: secret.name,
value: secret.name
})).sort();
return [{ label: 'None', value: NONE }, ...sortBy(mappedSecrets, 'label')];
},
keys() {
const secret = this.secrets.find((secret) => secret.name === this.name) || {};
const secret = (this.secrets || []).find((secret) => secret.name === this.name) || {};
return Object.keys(secret.data || {}).map((key) => ({
label: key,
value: key
}));
},
isView() {
return this.mode === _VIEW;
},
isKeyDisabled() {
return !this.isView && (!this.name || this.name === NONE || this.disabled);
}
},
methods: {
/**
* Provide a set of options for the LabelSelect ([none, ...{label, value}])
*/
mapSecrets(secrets) {
const mappedSecrets = secrets
.reduce((res, s) => {
if (s.kind === LABEL_SELECT_KINDS.NONE) {
return res;
}
if (s.id) {
res.push({ label: s.name, value: s.name });
} else {
res.push(s);
}
return res;
}, []);
return [
{
label: 'None', value: NONE, kind: LABEL_SELECT_KINDS.NONE
},
...mappedSecrets
];
},
/**
* @param [LabelSelectPaginationFunctionOptions] opts
* @returns LabelSelectPaginationFunctionOptions
*/
paginatePageOptions(opts) {
const { opts: { filter } } = opts;
const filters = !!filter ? [PaginationParamFilter.createSingleField({ field: 'metadata.name', value: filter })] : [];
filters.push(
PaginationParamFilter.createSingleField({ field: 'metadata.namespace', value: this.namespace }),
PaginationParamFilter.createSingleField({ field: 'metadata.fields.1', value: this.types.join(',') })
);
return {
...opts,
filters,
groupByNamespace: false,
classify: true,
sort: [{ asc: true, field: 'metadata.name' }],
};
},
}
};
</script>
@ -128,12 +198,17 @@ export default {
:class="{'show-key-selector': showKeySelector}"
>
<div class="input-container">
<LabeledSelect
<!-- key by namespace to ensure label select current page is recreated on ns change -->
<ResourceLabeledSelect
:key="namespace"
v-model="name"
:disabled="!isView && disabled"
:options="secretNames"
:label="secretNameLabel"
:mode="mode"
:resource-type="SECRET"
:in-store="inStore"
:paginated-resource-settings="paginateSecretsSetting"
:all-resources-settings="allSecretsSettings"
/>
<LabeledSelect
v-if="showKeySelector"

View File

@ -13,28 +13,22 @@
name: example-secret-name
key: example-secret-key
FIXME: The solution to above would have been to have a configurable path to set/get name and key from.
This would have avoided a lot of copy and paste
*/
import LabeledSelect from '@shell/components/form/LabeledSelect';
import ResourceLabeledSelect from '@shell/components/form/ResourceLabeledSelect';
import { SECRET } from '@shell/config/types';
import { _EDIT, _VIEW } from '@shell/config/query-params';
import { TYPES } from '@shell/models/secret';
import sortBy from 'lodash/sortBy';
import { LABEL_SELECT_KINDS } from '@shell/types/components/labeledSelect';
import { PaginationParamFilter } from '@shell/types/store/pagination.types';
const NONE = '__[[NONE]]__';
export default {
components: { LabeledSelect },
async fetch() {
// Make sure secrets are in the store so that the secret
// selectors in the receiver config forms will have secrets
// to choose from.
const allSecrets = await this.$store.dispatch('cluster/findAll', { type: SECRET });
const allSecretsInNamespace = allSecrets.filter((secret) => this.types.includes(secret._type) && secret.namespace === this.namespace);
this.secrets = allSecretsInNamespace;
},
components: { LabeledSelect, ResourceLabeledSelect },
props: {
test: { type: String, default: '' },
@ -74,24 +68,37 @@ export default {
data(props) {
return {
secrets: [],
name: props.initialName,
key: props.initialKey,
none: NONE
secrets: [],
name: props.initialName,
key: props.initialKey,
none: NONE,
SECRET,
allSecretsSettings: {
mapResult: (secrets) => {
const allSecretsInNamespace = secrets.filter((secret) => this.types.includes(secret._type) && secret.namespace === this.namespace);
const mappedSecrets = this.mapSecrets(allSecretsInNamespace.sort((a, b) => a.name.localeCompare(b.name)));
this.secrets = allSecretsInNamespace; // We need the key from the selected secret
return mappedSecrets;
}
},
paginateSecretsSetting: {
requestSettings: this.paginatePageOptions,
mapResult: (secrets) => {
const mappedSecrets = this.mapSecrets(secrets);
this.secrets = secrets; // We need the key from the selected secret. When paginating we won't touch the store, so just pass back here
return mappedSecrets;
}
}
};
},
computed: {
secretNames() {
const mappedSecrets = this.secrets.map((secret) => ({
label: secret.name,
value: secret.name
})).sort();
return [{ label: 'None', value: NONE }, ...sortBy(mappedSecrets, 'label')];
},
keys() {
const secret = this.secrets.find((secret) => secret.name === this.name) || {};
const secret = (this.secrets || []).find((secret) => secret.name === this.name) || {};
return Object.keys(secret.data || {}).map((key) => ({
label: key,
@ -107,6 +114,56 @@ export default {
},
methods: {
/**
* Provide a set of options for the LabelSelect ([none, ...{label, value}])
*/
mapSecrets(secrets) {
const mappedSecrets = secrets
.reduce((res, s) => {
if (s.kind === LABEL_SELECT_KINDS.NONE) {
return res;
}
if (s.id) {
res.push({ label: s.name, value: s.name });
} else {
res.push(s);
}
return res;
}, []);
return [
{
label: 'None', value: NONE, kind: LABEL_SELECT_KINDS.NONE
},
...mappedSecrets
];
},
/**
* @param [LabelSelectPaginationFunctionOptions] opts
* @returns LabelSelectPaginationFunctionOptions
*/
paginatePageOptions(opts) {
const { opts: { filter } } = opts;
const filters = !!filter ? [PaginationParamFilter.createSingleField({ field: 'metadata.name', value: filter })] : [];
filters.push(
PaginationParamFilter.createSingleField({ field: 'metadata.namespace', value: this.namespace }),
PaginationParamFilter.createSingleField({ field: 'metadata.fields.1', value: this.types.join(',') })
);
return {
...opts,
filters,
groupByNamespace: false,
classify: true,
sort: [{ asc: true, field: 'metadata.name' }],
};
},
updateSecretName(e) {
if (e.value === this.none) {
// The key should appear blank if the secret name is cleared
@ -128,13 +185,16 @@ export default {
<template>
<div class="secret-selector show-key-selector">
<div class="input-container">
<LabeledSelect
<ResourceLabeledSelect
v-model="name"
class="col span-6"
:disabled="!isView && disabled"
:options="secretNames"
:loading="$fetchState.pending"
:label="secretNameLabel"
:mode="mode"
:resource-type="SECRET"
:paginated-resource-settings="paginateSecretsSetting"
:all-resources-settings="allSecretsSettings"
@selecting="updateSecretName"
/>
<LabeledSelect

View File

@ -0,0 +1,139 @@
import { debounce } from 'lodash';
import { PropType, defineComponent } from 'vue';
import { ComputedOptions, MethodOptions } from 'vue/types/v3-component-options';
import { LabelSelectPaginateFn, LABEL_SELECT_NOT_OPTION_KINDS, LABEL_SELECT_KINDS } from '@shell/types/components/labeledSelect';
import paginationUtils from '@shell/utils/pagination-utils';
interface Props {
paginate?: LabelSelectPaginateFn
}
interface Data {
currentPage: number,
search: string,
pageSize: number,
page: any[],
pages: number,
totalResults: number,
paginating: boolean,
debouncedRequestPagination: Function
}
interface Computed extends ComputedOptions {
canPaginate: () => boolean,
canLoadMore: () => boolean,
optionsInPage: () => number,
optionCounts: () => string,
}
interface Methods extends MethodOptions {
loadMore: () => void
setPaginationFilter: (filter: string) => void
requestPagination: () => Promise<any>;
}
/**
* 'mixin' to provide pagination support to LabeledSelect
*/
export default defineComponent<Props, any, Data, Computed, Methods>({
props: {
paginate: {
default: null,
type: Function as PropType<LabelSelectPaginateFn>,
}
},
data(): Data {
return {
// Internal
currentPage: 1,
search: '',
pageSize: 10,
pages: 0,
debouncedRequestPagination: debounce(this.requestPagination, 700),
// External
page: [],
totalResults: 0,
paginating: false,
};
},
async mounted() {
if (this.canPaginate) {
await this.requestPagination();
}
},
computed: {
canPaginate() {
return !!this.paginate && paginationUtils.isEnabled({ rootGetters: this.$store.getters }, { store: 'cluster' });
},
canLoadMore() {
return this.pages > this.currentPage;
},
optionsInPage() {
// Number of genuine options (not groups, dividers, etc)
return this.canPaginate ? this._options.filter((o: any) => {
return o.kind !== LABEL_SELECT_KINDS.NONE && !LABEL_SELECT_NOT_OPTION_KINDS.includes(o.kind);
}).length : 0;
},
optionCounts() {
if (!this.canPaginate || this.optionsInPage === this.totalResults) {
return '';
}
return this.$store.getters['i18n/t']('labelSelect.pagination.counts', {
count: this.optionsInPage,
totalCount: this.totalResults
});
},
},
methods: {
loadMore() {
this.currentPage++;
this.requestPagination();
},
setPaginationFilter(filter: string) {
this.paginating = true; // Do this before debounce
this.currentPage = 1;
this.search = filter;
this.debouncedRequestPagination(true);
},
async requestPagination(resetPage = false) {
this.paginating = true;
const paginate: LabelSelectPaginateFn = this.paginate as LabelSelectPaginateFn; // Checking is done via prop
const {
page,
pages,
total
} = await paginate({
resetPage,
pageContent: this.page || [],
page: this.currentPage,
filter: this.search,
pageSize: this.pageSize,
});
this.page = page;
this.pages = pages || 0;
this.totalResults = total || 0;
this.paginating = false;
}
}
});

View File

@ -0,0 +1,122 @@
import { LabelSelectPaginateFnOptions, LabelSelectPaginateFnResponse } from '@shell/types/components/labeledSelect';
import { PaginationArgs, PaginationParam, PaginationSort } from '@shell/types/store/pagination.types';
export interface LabelSelectPaginationFunctionOptions<T = any> {
opts: LabelSelectPaginateFnOptions<T>,
/**
* Resource type
*/
type: string,
/**
* Store things
*/
ctx: { getters: any, dispatch: any}
/**
* Filters to apply. This mostly covers the text a user has entered, but could be other things like namespace
*/
filters?: PaginationParam[],
/**
* How to sort the response
*/
sort?: PaginationSort[],
/**
* Vuex store name
*/
store?: string,
/**
* True if the options returned should be grouped by namespace
*/
groupByNamespace?: boolean,
/**
* Convert the results from JSON object to Rancher model class instance
*/
classify?: boolean,
}
/**
* This is a helper function to cover common functionality that could happen when a LabelSelect requests a new page
*/
export async function labelSelectPaginationFunction<T>({
opts,
type,
ctx,
filters = [],
sort = [{ asc: true, field: 'metadata.namespace' }, { asc: true, field: 'metadata.name' }],
store = 'cluster',
groupByNamespace = true,
classify = false,
}: LabelSelectPaginationFunctionOptions<T>): Promise<LabelSelectPaginateFnResponse<T>> {
const {
pageContent, page, pageSize, resetPage
} = opts;
try {
// Construct params for request
const pagination = new PaginationArgs({
page,
pageSize,
sort,
filters
});
const url = ctx.getters[`${ store }/urlFor`](type, null, { pagination });
// Make request (note we're not bothering to persist anything to the store, response is transient)
const res = await ctx.dispatch(`${ store }/request`, { url });
let data = res.data;
if (classify) {
data = await ctx.dispatch('cluster/createMany', data);
}
const options = resetPage ? data : pageContent.concat(data);
// Create the new option collection by...
let resPage: any[];
if (groupByNamespace) {
// ... grouping by namespace
const namespaced: { [ns: string]: T[]} = {};
options.forEach((option: any) => {
const ns = option.metadata.namespace;
if (option.kind === 'group') { // this could contain a previous option set which contains groups
return;
}
if (!namespaced[ns]) {
namespaced[ns] = [];
}
namespaced[ns].push(option);
});
resPage = [];
// ... then sort groups by name and combined into a single array
Object.keys(namespaced).sort().forEach((ns) => {
resPage.push({
kind: 'group',
icon: 'icon-namespace',
id: ns,
metadata: { name: ns },
disabled: true,
});
resPage = resPage.concat(namespaced[ns]);
});
} else {
resPage = options;
}
return {
page: resPage,
pages: res.pages,
total: res.count
};
} catch (err) {
console.error(err); // eslint-disable-line no-console
}
return {
page: [], pages: 0, total: 0
};
}

View File

@ -1,4 +1,4 @@
import { PSADimension, PSALevel, PSAMode } from '@shell/types/pod-security-admission';
import { PSADimension, PSALevel, PSAMode } from '@shell/types/resources/pod-security-admission';
/**
* All the PSA labels are created with this prefix, so we can use this to identify them

View File

@ -2,15 +2,18 @@
import createEditView from '@shell/mixins/create-edit-view';
import CruResource from '@shell/components/CruResource';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import ResourceLabeledSelect from '@shell/components/form/ResourceLabeledSelect';
import { LabeledInput } from '@components/Form/LabeledInput';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import { mapGetters } from 'vuex';
import { CONFIG_MAP } from '@shell/config/types';
import { PaginationParamFilter } from '@shell/types/store/pagination.types';
const providers = ['aks', 'docker', 'eks', 'gke', 'k3s', 'minikube', 'rke-windows', 'rke', 'rke2'];
export default {
components: {
CruResource, LabeledSelect, LabeledInput, NameNsDescription
CruResource, LabeledSelect, ResourceLabeledSelect, LabeledInput, NameNsDescription
},
mixins: [createEditView],
@ -29,16 +32,19 @@ export default {
},
async fetch() {
this.configMaps = await this.$store.dispatch('cluster/findAll', { type: CONFIG_MAP });
},
data() {
if (!this.value.spec) {
this.$set(this.value, 'spec', {});
}
return { configMaps: [], providers };
return {
CONFIG_MAP,
providers,
configMapPaginateSettings: {
labelSelectOptions: { 'get-option-label': (opt) => opt?.metadata?.name || opt.id || opt },
requestSettings: this.pageRequestSettings,
}
};
},
computed: {
@ -55,7 +61,31 @@ export default {
this.$set(this.value.spec, 'customBenchmarkConfigMapNamespace', namespace);
}
},
...mapGetters({ t: 'i18n/t' })
...mapGetters({ t: 'i18n/t' }),
},
methods: {
/**
* @param [LabelSelectPaginationFunctionOptions] opts
* @returns LabelSelectPaginationFunctionOptions
*/
pageRequestSettings(opts) {
const { opts: { filter } } = opts;
return {
...opts,
classify: true,
filters: !!filter ? [PaginationParamFilter.createMultipleFields([
{
field: 'metadata.name', value: filter, equals: true
},
{
field: 'metadata.namespace', value: filter, equals: true
},
])] : []
};
},
}
};
</script>
@ -89,13 +119,15 @@ export default {
/>
</div>
<div class="col span-6">
<LabeledSelect
<ResourceLabeledSelect
v-model="customConfigMap"
:clearable="true"
option-key="id"
option-label="id"
:options="configMaps"
:mode="mode"
:label="t('cis.customConfigMap')"
:resource-type="CONFIG_MAP"
:paginated-resource-settings="configMapPaginateSettings"
/>
</div>
</div>

View File

@ -1,9 +1,12 @@
<script>
import KeyValue from '@shell/components/form/KeyValue';
import Select from '@shell/components/form/Select';
import LabeledSelect from '@shell/components/form/LabeledSelect';
export default {
components: { KeyValue, Select },
components: {
KeyValue, Select, LabeledSelect
},
props: {
mode: {
@ -23,11 +26,6 @@ export default {
default: () => [],
},
containers: {
type: Array,
default: () => [],
},
namespaces: {
type: Array,
default: () => [],
@ -89,15 +87,18 @@ export default {
</h3>
<div class="row">
<div class="col span-12">
<Select
<LabeledSelect
v-model="value.container_names"
class="lg"
:options="containers"
:mode="mode"
:options="[]"
:disabled="false"
:placeholder="t('logging.flow.matches.containerNames.placeholder')"
:multiple="true"
:taggable="true"
:clearable="true"
:searchable="true"
:close-on-select="false"
no-options-label-key="logging.flow.matches.containerNames.enter"
placement="top"
/>
</div>

View File

@ -6,14 +6,12 @@ import Loading from '@shell/components/Loading';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab';
import {
LOGGING, NAMESPACE, NODE, POD, SCHEMA
} from '@shell/config/types';
import { LOGGING, NAMESPACE, NODE, SCHEMA } from '@shell/config/types';
import jsyaml from 'js-yaml';
import { createYaml } from '@shell/utils/create-yaml';
import YamlEditor, { EDITOR_MODES } from '@shell/components/YamlEditor';
import { allHash } from '@shell/utils/promise';
import { isArray, uniq } from '@shell/utils/array';
import { isArray } from '@shell/utils/array';
import { matchRuleIsPopulated } from '@shell/models/logging.banzaicloud.io.flow';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { clone, set } from '@shell/utils/object';
@ -55,7 +53,6 @@ export default {
const hasAccessToOutputs = this.$store.getters[`cluster/schemaFor`](LOGGING.OUTPUT);
const hasAccessToNamespaces = this.$store.getters[`cluster/schemaFor`](NAMESPACE);
const hasAccessToNodes = this.$store.getters[`cluster/schemaFor`](NODE);
const hasAccessToPods = this.$store.getters[`cluster/schemaFor`](POD);
const isFlow = this.value.type === LOGGING.FLOW;
const getAllOrDefault = (type, hasAccess) => {
@ -67,7 +64,6 @@ export default {
allClusterOutputs: getAllOrDefault(LOGGING.CLUSTER_OUTPUT, hasAccessToClusterOutputs),
allNamespaces: getAllOrDefault(NAMESPACE, hasAccessToNamespaces),
allNodes: getAllOrDefault(NODE, hasAccessToNodes),
allPods: getAllOrDefault(POD, hasAccessToPods),
});
for ( const k of Object.keys(hash) ) {
@ -204,17 +200,6 @@ export default {
return out;
},
containerChoices() {
const out = [];
for ( const pod of this.allPods ) {
for ( const c of (pod.spec?.containers || []) ) {
out.push(c.name);
}
}
return uniq(out).sort();
},
},
watch: {
@ -386,7 +371,6 @@ export default {
:mode="mode"
:namespaces="namespaceChoices"
:nodes="nodeChoices"
:containers="containerChoices"
:is-cluster-flow="value.type === LOGGING.CLUSTER_FLOW"
@remove="e=>removeMatch(props.row.i)"
@input="e=>updateMatch(e,props.row.i)"

View File

@ -1,6 +1,6 @@
<script>
import CreateEditView from '@shell/mixins/create-edit-view';
import { SECRET, LOGGING, SCHEMA } from '@shell/config/types';
import { LOGGING, SCHEMA } from '@shell/config/types';
import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab';
import CruResource from '@shell/components/CruResource';
@ -24,10 +24,6 @@ export default {
mixins: [CreateEditView],
async fetch() {
await this.$store.dispatch('cluster/findAll', { type: SECRET });
},
data() {
const schemas = this.$store.getters['cluster/all'](SCHEMA);

View File

@ -5,7 +5,7 @@ import CruResource from '@shell/components/CruResource.vue';
import PodSecurityAdmission from '@shell/components/PodSecurityAdmission.vue';
import Loading from '@shell/components/Loading.vue';
import NameNsDescription from '@shell/components/form/NameNsDescription.vue';
import { PSA, PSAConfig, PSADefaults, PSAExemptions } from '@shell/types/pod-security-admission';
import { PSA, PSAConfig, PSADefaults, PSAExemptions } from '@shell/types/resources/pod-security-admission';
import { PSADimensions } from '@shell/config/pod-security-admission';
import { MANAGEMENT } from '@shell/config/types';

View File

@ -77,6 +77,11 @@ export default Vue.extend({
type: Boolean
},
filterable: {
default: true,
type: Boolean
},
rules: {
default: () => [],
type: Array,
@ -116,7 +121,11 @@ export default Vue.extend({
},
isSearchable(): boolean {
const { searchable } = this;
const { searchable, canPaginate } = this as any; // This will be resolved when we migrate from mixin
if (canPaginate) {
return true;
}
const options = ( this.options || [] );
if (searchable || options.length >= 10) {
@ -125,6 +134,17 @@ export default Vue.extend({
return false;
},
isFilterable(): boolean {
const { filterable, canPaginate } = this as any; // This will be resolved when we migrate from mixin
if (canPaginate) {
return false;
}
return filterable;
},
validationMessage(): string | undefined {
// we want to grab the required rule passed in if we can but if it's not there then we can just grab it from the formRulesGenerator
const requiredRule = this.rules.find((rule: any) => rule?.name === 'required') as Function;

View File

@ -387,10 +387,9 @@ export default {
/>
<Banner
v-if="!steveCacheEnabled"
v-clean-html="t(`performance.serverPagination.featureFlag`, { ffUrl }, true)"
color="warning"
>
<span v-clean-html="t(`performance.serverPagination.featureFlag`, { ffUrl }, true)" />
</Banner>
/>
<Checkbox
v-model="value.serverPagination.enabled"
:mode="mode"

View File

@ -0,0 +1,50 @@
export const LABEL_SELECT_KINDS = {
GROUP: 'group',
DIVIDER: 'divider',
NONE: 'none',
};
export const LABEL_SELECT_NOT_OPTION_KINDS = [
LABEL_SELECT_KINDS.GROUP,
LABEL_SELECT_KINDS.DIVIDER,
];
/**
* Options used When LabelSelect requests a new page
*/
export interface LabelSelectPaginateFnOptions<T = any> {
/**
* Current page
*/
pageContent: T[],
/**
* page number to fetch
*/
page: number,
/**
* number of items in the page to fetch
*/
pageSize: number,
/**
* filter pagination filter. this is just a text string associated with user entered text
*/
filter: string,
/**
* true if the result should only contain the fetched page, false if the result should be added to the pageContent
*/
resetPage: boolean,
}
/**
* Response that LabelSelect needs when it's requested a new page
*/
export interface LabelSelectPaginateFnResponse<T = any> {
page: T[],
pages: number,
total: number
}
/**
* Function called when LabelSelect needs a new page
*/
export type LabelSelectPaginateFn<T = any> = (opts: LabelSelectPaginateFnOptions<T>) => Promise<LabelSelectPaginateFnResponse<T>>

View File

@ -1,4 +1,4 @@
import { PSA } from '@shell/types/pod-security-admission';
import { PSA } from '@shell/types/resources/pod-security-admission';
import { getPSATooltipsDescription } from '@shell/utils/pod-security-admission';
describe('fX: getPSATooltipsDescription', () => {

View File

@ -14,6 +14,18 @@ import { sameArrayObjects } from '@shell/utils/array';
import { isEqual } from '@shell/utils/object';
import { STEVE_CACHE } from '@shell/store/features';
import { getPerformanceSetting } from '@shell/utils/settings';
import { DEFAULT_PERF_SETTING } from '@shell/config/settings';
/**
* Given the vai cache changes haven't merged, work around the settings that are blocked by it
*
* Once cache is merged (pre 2.9.0) this will be removed
*/
const TEMP_VAI_CACHE_MERGED = false;
/**
* Given above, just a dev thing
*/
const TEMP_PERF_ENABLED = false;
/**
* Helper functions for server side pagination
@ -32,6 +44,11 @@ class PaginationUtils {
return perf.serverPagination;
}
isSteveCacheEnabled({ rootGetters }: any): boolean {
// We always get Feature flags as part of start up (see `dispatch('features/loadServer')` in loadManagement)
return TEMP_VAI_CACHE_MERGED || rootGetters['features/get']?.(STEVE_CACHE);
}
/**
* Is pagination enabled at a global level or for a specific resource
*/
@ -41,13 +58,16 @@ class PaginationUtils {
id: string,
}
}) {
const settings = this.getSettings({ rootGetters });
// Cache must be enabled to support pagination api
if (!rootGetters['features/get']?.(STEVE_CACHE)) {
if (!this.isSteveCacheEnabled({ rootGetters })) {
return false;
}
const settings = TEMP_PERF_ENABLED ? {
...DEFAULT_PERF_SETTING.serverPagination,
enabled: true,
} : this.getSettings({ rootGetters });
// No setting, not enabled
if (!settings?.enabled) {
return false;

View File

@ -1,7 +1,7 @@
import { reduce, filter, keys } from 'lodash';
import { PSALabelPrefix, PSALabelsNamespaces } from '@shell/config/pod-security-admission';
import { camelToTitle } from '@shell/utils/string';
import { PSA } from '@shell/types/pod-security-admission';
import { PSA } from '@shell/types/resources/pod-security-admission';
/**
* Return PSA labels present in the resource