Merge branch 'master' of github.com:rancher/dashboard into 12485-repositories-disabling-feature

This commit is contained in:
Mo Mesgin 2025-01-30 10:43:25 -08:00
commit face94e4ff
19 changed files with 276 additions and 47 deletions

View File

@ -7,4 +7,8 @@ export default class FleetClusterList extends BaseResourceList {
details(name: string, index: number) { details(name: string, index: number) {
return this.resourceTable().sortableTable().rowWithName(name).column(index); return this.resourceTable().sortableTable().rowWithName(name).column(index);
} }
subRows() {
return this.resourceTable().sortableTable().subRows();
}
} }

View File

@ -70,7 +70,8 @@ describe('Fleet Clusters', { tags: ['@fleet', '@adminUser'] }, () => {
rke2ClusterAmazon: { rke2ClusterAmazon: {
clusterName: name, clusterName: name,
namespace, namespace,
} },
metadata: { labels: { foo: 'bar' } }
}).then(() => { }).then(() => {
removeCluster = true; removeCluster = true;
}); });
@ -123,6 +124,8 @@ describe('Fleet Clusters', { tags: ['@fleet', '@adminUser'] }, () => {
fleetClusterListPage.clusterList().details(clusterName, 4).should('have.text', '1'); fleetClusterListPage.clusterList().details(clusterName, 4).should('have.text', '1');
// check resources: testing https://github.com/rancher/dashboard/issues/11154 // check resources: testing https://github.com/rancher/dashboard/issues/11154
fleetClusterListPage.clusterList().details(clusterName, 5).contains( ' 1 ', MEDIUM_TIMEOUT_OPT); fleetClusterListPage.clusterList().details(clusterName, 5).contains( ' 1 ', MEDIUM_TIMEOUT_OPT);
// check cluster labels
fleetClusterListPage.clusterList().subRows().should('contain.text', 'foo=bar');
const fleetClusterDetailsPage = new FleetClusterDetailsPo(namespace, clusterName); const fleetClusterDetailsPage = new FleetClusterDetailsPo(namespace, clusterName);

View File

@ -29,7 +29,7 @@ export type CreateAmazonRke2ClusterParams = {
type: string, type: string,
clusterName: string, clusterName: string,
namespace: string namespace: string
}, },
cloudCredentialsAmazon: { cloudCredentialsAmazon: {
workspace: string, workspace: string,
name: string, name: string,
@ -40,7 +40,11 @@ export type CreateAmazonRke2ClusterParams = {
rke2ClusterAmazon: { rke2ClusterAmazon: {
clusterName: string, clusterName: string,
namespace: string, namespace: string,
} },
metadata?: {
labels?: { [key: string]: string },
annotations?: { [key: string]: string },
},
} }
export type CreateAmazonRke2ClusterWithoutMachineConfigParams = { export type CreateAmazonRke2ClusterWithoutMachineConfigParams = {
cloudCredentialsAmazon: { cloudCredentialsAmazon: {

View File

@ -602,7 +602,9 @@ Cypress.Commands.add('deleteNodeTemplate', (nodeTemplateId, timeout = 30000, fai
* Create RKE2 cluster with Amazon EC2 cloud provider * Create RKE2 cluster with Amazon EC2 cloud provider
*/ */
Cypress.Commands.add('createAmazonRke2Cluster', (params: CreateAmazonRke2ClusterParams) => { Cypress.Commands.add('createAmazonRke2Cluster', (params: CreateAmazonRke2ClusterParams) => {
const { machineConfig, rke2ClusterAmazon, cloudCredentialsAmazon } = params; const {
machineConfig, rke2ClusterAmazon, cloudCredentialsAmazon, metadata
} = params;
return cy.createAwsCloudCredentials(cloudCredentialsAmazon.workspace, cloudCredentialsAmazon.name, cloudCredentialsAmazon.region, cloudCredentialsAmazon.accessKey, cloudCredentialsAmazon.secretKey) return cy.createAwsCloudCredentials(cloudCredentialsAmazon.workspace, cloudCredentialsAmazon.name, cloudCredentialsAmazon.region, cloudCredentialsAmazon.accessKey, cloudCredentialsAmazon.secretKey)
.then((resp: Cypress.Response<any>) => { .then((resp: Cypress.Response<any>) => {
@ -625,7 +627,11 @@ Cypress.Commands.add('createAmazonRke2Cluster', (params: CreateAmazonRke2Cluster
type: 'provisioning.cattle.io.cluster', type: 'provisioning.cattle.io.cluster',
metadata: { metadata: {
namespace: rke2ClusterAmazon.namespace, namespace: rke2ClusterAmazon.namespace,
annotations: { 'field.cattle.io/description': `${ rke2ClusterAmazon.clusterName }-description` }, annotations: {
'field.cattle.io/description': `${ rke2ClusterAmazon.clusterName }-description`,
...(metadata?.annotations || {}),
},
labels: metadata?.labels || {},
name: rke2ClusterAmazon.clusterName name: rke2ClusterAmazon.clusterName
}, },
spec: { spec: {

View File

@ -76,7 +76,7 @@
"cookie": "0.7.0", "cookie": "0.7.0",
"cookie-universal": "2.2.2", "cookie-universal": "2.2.2",
"cron-validator": "1.2.0", "cron-validator": "1.2.0",
"cronstrue": "1.95.0", "cronstrue": "2.53.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"custom-event-polyfill": "1.0.7", "custom-event-polyfill": "1.0.7",
"d3": "7.3.0", "d3": "7.3.0",

View File

@ -36,7 +36,7 @@
"babel-eslint": "10.1.0", "babel-eslint": "10.1.0",
"core-js": "3.40.0", "core-js": "3.40.0",
"cron-validator": "1.3.1", "cron-validator": "1.3.1",
"cronstrue": "2.50.0", "cronstrue": "2.53.0",
"eslint-plugin-import": "2.31.0", "eslint-plugin-import": "2.31.0",
"eslint-plugin-node": "11.1.0", "eslint-plugin-node": "11.1.0",
"eslint-plugin-promise": "5.2.0", "eslint-plugin-promise": "5.2.0",

View File

@ -20,7 +20,7 @@ describe('component: LabeledInput', () => {
expect(wrapper.emitted('update:value')![0][0]).toBe(value); expect(wrapper.emitted('update:value')![0][0]).toBe(value);
}); });
it('using mode "multiline" should emit input value correctly', () => { it('using type "multiline" should emit input value correctly', () => {
const value = 'any-string'; const value = 'any-string';
const delay = 1; const delay = 1;
const wrapper = mount(LabeledInput, { const wrapper = mount(LabeledInput, {
@ -37,4 +37,21 @@ describe('component: LabeledInput', () => {
expect(wrapper.emitted('update:value')).toHaveLength(1); expect(wrapper.emitted('update:value')).toHaveLength(1);
expect(wrapper.emitted('update:value')![0][0]).toBe(value); expect(wrapper.emitted('update:value')![0][0]).toBe(value);
}); });
describe('using type "chron"', () => {
it.each([
['0 * * * *', 'Every hour, every day'],
['@daily', 'At 12:00 AM, every day'],
['You must fail! Go!', '%generic.invalidCron%'],
])('passing value %p should display hint %p', (value, hint) => {
const wrapper = mount(LabeledInput, {
propsData: { value, type: 'cron' },
mocks: { $store: { getters: { 'i18n/t': jest.fn() } } }
});
const subLabel = wrapper.find('[data-testid="sub-label"]');
expect(subLabel.text()).toBe(hint);
});
});
}); });

View File

@ -179,14 +179,28 @@ export default defineComponent({
if (this.type !== 'cron' || !this.value) { if (this.type !== 'cron' || !this.value) {
return; return;
} }
// TODO - #13202: This is required due use of 2 libraries and 3 different libraries through the code.
const predefined = [
'@yearly',
'@annually',
'@monthly',
'@weekly',
'@daily',
'@midnight',
'@hourly'
];
const isPredefined = predefined.includes(this.value as string);
// refer https://github.com/GuillaumeRochat/cron-validator#readme // refer https://github.com/GuillaumeRochat/cron-validator#readme
if (!isValidCron(this.value as string, { if (!isPredefined && !isValidCron(this.value as string, {
alias: true, alias: true,
allowBlankDay: true, allowBlankDay: true,
allowSevenAsSunday: true, allowSevenAsSunday: true,
})) { })) {
return this.t('generic.invalidCron'); return this.t('generic.invalidCron');
} }
try { try {
const hint = cronstrue.toString(this.value as string || '', { verbose: true }); const hint = cronstrue.toString(this.value as string || '', { verbose: true });
@ -382,6 +396,7 @@ export default defineComponent({
<div <div
v-if="cronHint || subLabel" v-if="cronHint || subLabel"
class="sub-label" class="sub-label"
data-testid="sub-label"
> >
<div <div
v-if="cronHint" v-if="cronHint"

View File

@ -2385,6 +2385,9 @@ fleet:
cluster: cluster:
summary: Resource Summary summary: Resource Summary
nonReady: Non-Ready Bundles nonReady: Non-Ready Bundles
labels: Labels
hideLabels: Show less
showLabels: Show more
clusters: clusters:
harvester: |- harvester: |-
There {count, plural, There {count, plural,

View File

@ -1,10 +1,11 @@
<script> <script>
import ResourceTable from '@shell/components/ResourceTable'; import ResourceTable from '@shell/components/ResourceTable';
import Tag from '@shell/components/Tag.vue';
import { STATE, NAME, AGE, FLEET_SUMMARY } from '@shell/config/table-headers'; import { STATE, NAME, AGE, FLEET_SUMMARY } from '@shell/config/table-headers';
import { FLEET, MANAGEMENT } from '@shell/config/types'; import { FLEET, MANAGEMENT } from '@shell/config/types';
export default { export default {
components: { ResourceTable }, components: { ResourceTable, Tag },
props: { props: {
rows: { rows: {
@ -75,6 +76,12 @@ export default {
pluralLabel: this.$store.getters['type-map/labelFor'](schema, 99), pluralLabel: this.$store.getters['type-map/labelFor'](schema, 99),
}; };
}, },
},
methods: {
toggleCustomLabels(row) {
row['displayCustomLabels'] = !row.displayCustomLabels;
}
} }
}; };
</script> </script>
@ -85,6 +92,7 @@ export default {
:schema="schema" :schema="schema"
:headers="headers" :headers="headers"
:rows="rows" :rows="rows"
:sub-rows="true"
:loading="loading" :loading="loading"
:use-query-params-for-simple-filtering="useQueryParamsForSimpleFiltering" :use-query-params-for-simple-filtering="useQueryParamsForSimpleFiltering"
key-field="_key" key-field="_key"
@ -123,5 +131,78 @@ export default {
:class="{'text-error': !row.bundleInfo.total}" :class="{'text-error': !row.bundleInfo.total}"
>{{ row.bundleInfo.total }}</span> >{{ row.bundleInfo.total }}</span>
</template> </template>
<template #sub-row="{fullColspan, row, onRowMouseEnter, onRowMouseLeave}">
<tr
class="labels-row sub-row"
@mouseenter="onRowMouseEnter"
@mouseleave="onRowMouseLeave"
>
<template v-if="row.customLabels.length">
<td>&nbsp;</td>
<td>&nbsp;</td>
<td :colspan="fullColspan-2">
<span
v-if="row.customLabels.length"
class="mt-5"
> {{ t('fleet.cluster.labels') }}:
<span
v-for="(label, i) in row.customLabels"
:key="i"
class="mt-5 labels"
>
<Tag
v-if="i < 7"
class="mr-5 label"
>
{{ label }}
</Tag>
<Tag
v-else-if="i > 6 && row.displayCustomLabels"
class="mr-5 label"
>
{{ label }}
</Tag>
</span>
<a
v-if="row.customLabels.length > 7"
href="#"
@click.prevent="toggleCustomLabels(row)"
>
{{ t(`fleet.cluster.${row.displayCustomLabels? 'hideLabels' : 'showLabels'}`) }}
</a>
</span>
</td>
</template>
<td
v-else
:colspan="fullColspan"
>
&nbsp;
</td>
</tr>
</template>
</ResourceTable> </ResourceTable>
</template> </template>
<style lang='scss' scoped>
.labels-row {
td {
padding-top:0;
.tag {
margin-right: 5px;
display: inline-block;
margin-top: 2px;
}
}
}
.labels {
display: inline;
flex-wrap: wrap;
.label {
display: inline-block;
margin-top: 2px;
}
}
</style>

View File

@ -1,5 +1,5 @@
import { LOCAL_CLUSTER, MANAGEMENT, NORMAN } from '@shell/config/types'; import { LOCAL_CLUSTER, MANAGEMENT, NORMAN } from '@shell/config/types';
import { CAPI, FLEET as FLEET_LABELS } from '@shell/config/labels-annotations'; import { CAPI, FLEET as FLEET_LABELS, SYSTEM_LABELS } from '@shell/config/labels-annotations';
import { _RKE2 } from '@shell/store/prefs'; import { _RKE2 } from '@shell/store/prefs';
import SteveModel from '@shell/plugins/steve/steve-class'; import SteveModel from '@shell/plugins/steve/steve-class';
import { escapeHtml } from '@shell/utils/string'; import { escapeHtml } from '@shell/utils/string';
@ -190,6 +190,22 @@ export default class FleetCluster extends SteveModel {
} }
} }
get customLabels() {
const parsedLabels = [];
if (this.labels) {
for (const k in this.labels) {
const [prefix] = k.split('/');
if (!SYSTEM_LABELS.includes(prefix) && k !== CAPI.PROVIDER) {
parsedLabels.push(`${ k }=${ this.labels[k] }`);
}
}
}
return parsedLabels;
}
async saveYaml(yaml) { async saveYaml(yaml) {
await this._saveYaml(yaml); await this._saveYaml(yaml);

View File

@ -64,7 +64,7 @@
"cookie": "0.7.0", "cookie": "0.7.0",
"core-js": "3.40.0", "core-js": "3.40.0",
"cron-validator": "1.3.1", "cron-validator": "1.3.1",
"cronstrue": "2.50.0", "cronstrue": "2.53.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"css-loader": "6.7.3", "css-loader": "6.7.3",
"csv-loader": "3.0.3", "csv-loader": "3.0.3",

View File

@ -432,7 +432,8 @@ class StevePaginationUtils extends NamespaceProjectFilters {
// Check if the API supports filtering by this field // Check if the API supports filtering by this field
this.validateField(validateFields, schema, field.field); this.validateField(validateFields, schema, field.field);
const exactPartial = field.exact ? `'${ field.value }'` : field.value; const value = encodeURIComponent(field.value);
const exactPartial = field.exact ? `'${ value }'` : value;
return `${ this.convertArrayPath(field.field) }${ field.equals ? '=' : '!=' }${ exactPartial }`; return `${ this.convertArrayPath(field.field) }${ field.equals ? '=' : '!=' }${ exactPartial }`;
} }

View File

@ -2,8 +2,13 @@ import cronstrue from 'cronstrue';
export function cronSchedule(schedule = '', getters, errors) { export function cronSchedule(schedule = '', getters, errors) {
try { try {
cronstrue.toString(schedule, { verbose: true }); cronScheduleRule.validation(schedule);
} catch (e) { } catch (e) {
errors.push(getters['i18n/t']('validation.invalidCron')); errors.push(getters['i18n/t'](cronScheduleRule.message));
} }
} }
export const cronScheduleRule = {
validation: (text) => cronstrue.toString(text, { verbose: true }),
message: 'validation.invalidCron'
};

View File

@ -27,22 +27,6 @@ describe('formRules', () => {
expect(formRuleResult).toStrictEqual(expectedResult); expect(formRuleResult).toStrictEqual(expectedResult);
}); });
it('"cronSchedule" : returns undefined when valid cron string value supplied', () => {
const testValue = '0 * * * *';
const formRuleResult = formRules.cronSchedule(testValue);
expect(formRuleResult).toBeUndefined();
});
it('"cronSchedule" : returns the correct message when invalid cron string value supplied', () => {
// specific logic of what constitutes a cron string is in the "cronstrue" function in an external library and not tested here
const testValue = '0 * * **';
const formRuleResult = formRules.cronSchedule(testValue);
const expectedResult = JSON.stringify({ message: 'validation.invalidCron' });
expect(formRuleResult).toStrictEqual(expectedResult);
});
it('"https" : returns undefined when valid https url value is supplied', () => { it('"https" : returns undefined when valid https url value is supplied', () => {
const testValue = 'https://url.com'; const testValue = 'https://url.com';
const formRuleResult = formRules.https(testValue); const formRuleResult = formRules.https(testValue);
@ -1112,6 +1096,13 @@ describe('formRules', () => {
expect(formRuleResult).toStrictEqual(expectedResult); expect(formRuleResult).toStrictEqual(expectedResult);
}); });
/**
* Test all factory validators
* @param rule - the name of the factory validator
* @param argument - the value to validate
* @param correctValues - an array of values that should pass the validation
* @param wrongValues - an array of values that should fail the validation
*/
describe.each([ describe.each([
['minValue', 2, [3], [1]], ['minValue', 2, [3], [1]],
['maxValue', 256, [1], [300]], ['maxValue', 256, [1], [300]],
@ -1133,12 +1124,18 @@ describe('formRules', () => {
}); });
}); });
/**
* Test all standard validators
* @param rule - the name of the standard validator
* @param correctValues - an array of values that should pass the validation
* @param wrongValues - an array of values that should fail the validation
*/
describe.each([ describe.each([
['requiredInt', [2, 2.2], ['e']], ['requiredInt', [2, 2.2], ['e']],
['isInteger', ['2', 2, 0], [2.2, 'e', '1.0']], ['isInteger', ['2', 2, 0], [2.2, 'e', '1.0']],
['isPositive', ['0', 1], [-1]], ['isPositive', ['0', 1], [-1]],
['isOctal', ['0', 0, 10], ['01']], ['isOctal', ['0', 0, 10], ['01']],
['cronSchedule', ['0 * * * *', '@daily'], ['0 * * **']],
])('given validator %p', (rule, correctValues, wrongValues) => { ])('given validator %p', (rule, correctValues, wrongValues) => {
it.each(wrongValues as [])('should return error for value %p', (wrong) => { it.each(wrongValues as [])('should return error for value %p', (wrong) => {
const formRuleResult = (formRules as any)[rule](wrong); const formRuleResult = (formRules as any)[rule](wrong);

View File

@ -4,14 +4,26 @@ import isEmpty from 'lodash/isEmpty';
import has from 'lodash/has'; import has from 'lodash/has';
import isUrl from 'is-url'; import isUrl from 'is-url';
// import uniq from 'lodash/uniq'; // import uniq from 'lodash/uniq';
import cronstrue from 'cronstrue';
import { Translation } from '@shell/types/t'; import { Translation } from '@shell/types/t';
import { isHttps, isLocalhost, hasTrailingForwardSlash } from '@shell/utils/validators/setting'; import { isHttps, isLocalhost, hasTrailingForwardSlash } from '@shell/utils/validators/setting';
import { cronScheduleRule } from '@shell/utils/validators/cron-schedule';
// import uniq from 'lodash/uniq'; // import uniq from 'lodash/uniq';
export type Validator<T = undefined | string> = (val: any, arg?: any) => T;
export type ValidatorFactory = (arg1: any, arg2?: any) => Validator /**
* Fixed validation rule which require only the value to be evaluated
* @param value
* @returns { string | undefined }
*/
export type Validator<T = undefined | string> = (value: any, arg?: any) => T;
/**
* Factory function which returns a validation rule
* @param arg Argument used as part of the validation rule process, not necessarily as parameter of the validation rule
* @param value Value to be evaluated
* @returns { Validator }
*/
export type ValidatorFactory = (arg: any, value?: any) => Validator
type ServicePort = { type ServicePort = {
name?: string, name?: string,
@ -131,9 +143,9 @@ export default function(t: Translation, { key = 'Value' }: ValidationOptions): {
const cronSchedule: Validator = (val: string) => { const cronSchedule: Validator = (val: string) => {
try { try {
cronstrue.toString(val, { verbose: true }); cronScheduleRule.validation(val);
} catch (e) { } catch (e) {
return t('validation.invalidCron'); return t(cronScheduleRule.message);
} }
}; };

View File

@ -34,3 +34,23 @@ Input elements with type text allow users to enter any combination of lett
#### Error #### Error
<Canvas of={LabeledInput.Error} /> <Canvas of={LabeledInput.Error} />
#### SubLabel
Addition information can be added to the input field using the `subLabel` prop.
<Canvas of={LabeledInput.SubLabel} />
#### Cron Type
The `cron` prop can be used to add [Cron language](https://en.wikipedia.org/wiki/Cron) hints.
<Canvas of={LabeledInput.CronType} />
<Canvas of={LabeledInput.CronTypeDaily} />
<Canvas of={LabeledInput.CronTypeError} />
#### Multiline Type
<Canvas of={LabeledInput.MultilineType} />

View File

@ -64,3 +64,53 @@ export const Error: Story = {
tooltipKey: 'Error message' tooltipKey: 'Error message'
}, },
}; };
export const SubLabel: Story = {
...Default,
args: {
label: 'Name',
subLabel: 'Additional information',
type: 'text',
value: 'Simon',
},
};
export const CronType: Story = {
...Default,
args: {
label: 'Period',
type: 'cron',
value: '0 * * * *',
},
};
export const CronTypeDaily: Story = {
...Default,
args: {
label: 'Period',
type: 'cron',
value: '@daily',
},
};
export const CronTypeError: Story = {
...Default,
args: {
label: 'Period',
type: 'cron',
value: 'not a cron expression',
},
};
export const MultilineType: Story = {
...Default,
args: {
label: 'Period',
type: 'multiline',
value: `this
is
a
multiline
text`,
},
};

View File

@ -6386,15 +6386,10 @@ cron-validator@1.3.1:
resolved "https://registry.yarnpkg.com/cron-validator/-/cron-validator-1.3.1.tgz#8f2fe430f92140df77f91178ae31fc1e3a48a20e" resolved "https://registry.yarnpkg.com/cron-validator/-/cron-validator-1.3.1.tgz#8f2fe430f92140df77f91178ae31fc1e3a48a20e"
integrity sha512-C1HsxuPCY/5opR55G5/WNzyEGDWFVG+6GLrA+fW/sCTcP6A6NTjUP2AK7B8n2PyFs90kDG2qzwm8LMheADku6A== integrity sha512-C1HsxuPCY/5opR55G5/WNzyEGDWFVG+6GLrA+fW/sCTcP6A6NTjUP2AK7B8n2PyFs90kDG2qzwm8LMheADku6A==
cronstrue@1.95.0: cronstrue@2.53.0:
version "1.95.0" version "2.53.0"
resolved "https://registry.npmjs.org/cronstrue/-/cronstrue-1.95.0.tgz#171df1fad8b0f0cb636354dd1d7842161c15478f" resolved "https://registry.npmjs.org/cronstrue/-/cronstrue-2.53.0.tgz#5bbcd7483636b99379480f624faef5056f3efbd8"
integrity sha512-CdbQ17Z8Na2IdrK1SiD3zmXfE66KerQZ8/iApkGsxjmUVGJPS9M9oK4FZC3LM6ohUjjq3UeaSk+90Cf3QbXDfw== integrity sha512-CkAcaI94xL8h6N7cGxgXfR5D7oV2yVtDzB9vMZP8tIgPyEv/oc/7nq9rlk7LMxvc3N+q6LKZmNLCVxJRpyEg8A==
cronstrue@2.50.0:
version "2.50.0"
resolved "https://registry.yarnpkg.com/cronstrue/-/cronstrue-2.50.0.tgz#eabba0f915f186765258b707b7a3950c663b5573"
integrity sha512-ULYhWIonJzlScCCQrPUG5uMXzXxSixty4djud9SS37DoNxDdkeRocxzHuAo4ImRBUK+mAuU5X9TSwEDccnnuPg==
cross-env@7.0.3: cross-env@7.0.3:
version "7.0.3" version "7.0.3"