MSP Dashboard: Add labels to profiles page (#23145)

#22073

Changes:
- Added the ability to use labels to assign configuration profiles on
the profiles page.
This commit is contained in:
Eric 2024-12-05 17:39:47 -06:00 committed by GitHub
parent 6514631dcd
commit 102f9f3cb1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 523 additions and 140 deletions

View file

@ -0,0 +1,45 @@
module.exports = {
friendlyName: 'Get labels',
description: 'Builds and returns an array of labels on the Fleet instance.',
exits: {
success: {
outputType: [{}],
}
},
fn: async function () {
let labelsOnThisInstance = [];
let labelsResponseData = await sails.helpers.http.get.with({
url: '/api/v1/fleet/labels',
baseUrl: sails.config.custom.fleetBaseUrl,
headers: {
Authorization: `Bearer ${sails.config.custom.fleetApiToken}`
}
})
.timeout(120000)
.retry(['requestFailed', {name: 'TimeoutError'}]);
for(let label of labelsResponseData.labels) {
labelsOnThisInstance.push({
name: label.name,
value: label.id
});
}
labelsOnThisInstance = _.sortBy(labelsOnThisInstance, 'name');
// All done.
return labelsOnThisInstance;
}
};

View file

@ -13,7 +13,9 @@ module.exports = {
},
fn: async function () {
// Get all teams on the Fleet instance.
let teamsResponseData = await sails.helpers.http.get.with({
url: '/api/v1/fleet/teams',
@ -57,7 +59,6 @@ module.exports = {
let profilesForThisTeam = configurationProfilesResponseData.profiles;
allProfiles = allProfiles.concat(profilesForThisTeam);
}
// Add the configurations profiles that are assigned to the "no team" team.
let noTeamConfigurationProfilesResponseData = await sails.helpers.http.get.with({
url: '/api/v1/fleet/configuration_profiles',
@ -68,45 +69,53 @@ module.exports = {
})
.timeout(120000)
.retry(['requestFailed', {name: 'TimeoutError'}]);
let profilesForThisTeam = noTeamConfigurationProfilesResponseData.profiles;
allProfiles = allProfiles.concat(profilesForThisTeam);
// console.log(allProfiles);
let profilesOnThisFleetInstance = [];
// Group configuration profiles by their identifier.
let allProfilesByIdentifier = _.groupBy(allProfiles, 'identifier');
for(let profileIdentifier in allProfilesByIdentifier) {
// Iterate through the arrays of profiles with the same unique identifier.
let teamsForThisProfile = [];
// Add the profile's UUID and information about the team this profile is assigned to the teams array for profiles.
for(let profile of allProfilesByIdentifier[profileIdentifier]){
let informationAboutThisProfile = {
let profilesForNoTeam = noTeamConfigurationProfilesResponseData.profiles;
allProfiles = allProfiles.concat(profilesForNoTeam);
let profilesInformation = [];
for(let profile of allProfiles) {
let profileInformation = {
name: profile.name,
identifier: profile.identifier,
platform: profile.platform,
createdAt: new Date(profile.created_at).getTime(),
team: {
uuid: profile.profile_uuid,
fleetApid: profile.team_id,
teamName: _.find(teams, {fleetApid: profile.team_id}).teamName,
};
teamsForThisProfile.push(informationAboutThisProfile);
}
let profile = allProfilesByIdentifier[profileIdentifier][0];// Grab the first profile returned in the api repsonse to build our profile configuration.
let profileInformation = {
name: profile.name,
identifier: profileIdentifier,
platform: profile.platform,
createdAt: new Date(profile.created_at).getTime(),
teams: teamsForThisProfile
},
profileTarget: 'all',
};
profilesOnThisFleetInstance.push(profileInformation);
if(profile.labels_include_all) {
profileInformation.labels = _.pluck(profile.labels_include_all, 'name');
profileInformation.profileTarget = 'custom';
profileInformation.labelTargetBehavior = 'include';
} else if(profile.labels_exclude_any){
profileInformation.labels = _.pluck(profile.labels_exclude_any, 'name');
profileInformation.profileTarget = 'custom';
profileInformation.labelTargetBehavior = 'exclude';
}
profilesInformation.push(profileInformation);
}
// Group the profiles based on identifier, labels, and labelTargetBehavior
let profilesGroupedbyLabelsAndIdentifier = _.groupBy(profilesInformation, (profile)=>{
return `${profile.identifier}|${JSON.stringify(profile.labels)}|${profile.labelTargetBehavior}`;
});
// map the grouped profiles and merge profiles that have the same labels, target behavior, and identifier.
let allProfilesOnFleetInstance = Object.values(profilesGroupedbyLabelsAndIdentifier).map(profileGroup => {
return {
...profileGroup[0],// Expand the first item in the profileGroup
teams: profileGroup.map(item => item.team)// Merge the teams arrays
};
});
// Get the undeployed profiles from the app's database.
let undeployedProfiles = await UndeployedProfile.find();
profilesOnThisFleetInstance = _.union(profilesOnThisFleetInstance, undeployedProfiles);
allProfilesOnFleetInstance = _.union(allProfilesOnFleetInstance, undeployedProfiles);
// Sort profiles by their name.
profilesOnThisFleetInstance = _.sortByOrder(profilesOnThisFleetInstance, 'name', 'asc');
return profilesOnThisFleetInstance;
allProfilesOnFleetInstance = _.sortByOrder(allProfilesOnFleetInstance, 'name', 'asc');
// return the updated list of profiles
return allProfilesOnFleetInstance;
}

View file

@ -22,6 +22,20 @@ module.exports = {
type: 'ref',
description: 'A file that will be replacing the profile.'
},
profileTarget: {
type: 'string',
description: 'The target for this configuration profile',
defaultsTo: 'all',
isIn: ['all', 'custom'],
},
labelTargetBehavior: {
type: 'string',
isIn: ['include', 'exclude'],
},
labels: {
type: ['string'],
description: 'A list of the names of labels that will be included/excluded.'
}
},
@ -34,7 +48,7 @@ module.exports = {
fn: async function ({profile, newTeamIds, newProfile}) {
fn: async function ({profile, newTeamIds, newProfile, profileTarget, labelTargetBehavior, labels}) {
if(newProfile.isNoop){
newProfile.noMoreFiles();
newProfile = undefined;
@ -92,6 +106,8 @@ module.exports = {
filename = profile.name;
extension = profile.profileType;
}
// ╔═╗╔═╗╔═╗╦╔═╗╔╗╔ ╔═╗╦═╗╔═╗╔═╗╦╦ ╔═╗
// ╠═╣╚═╗╚═╗║║ ╦║║║ ╠═╝╠╦╝║ ║╠╣ ║║ ║╣
// ╩ ╩╚═╝╚═╝╩╚═╝╝╚╝ ╩ ╩╚═╚═╝╚ ╩╩═╝╚═╝
@ -116,21 +132,24 @@ module.exports = {
}
for(let teamApid of addedTeams){
// console.log(`Adding ${profile.name} to team id ${teamApid}`);
let bodyForThisRequest = {
team_id: teamApid,// eslint-disable-line camelcase
labels_exclude_any: labelTargetBehavior === 'exclude' ? labels : undefined,// eslint-disable-line camelcase
labels_include_all: labelTargetBehavior === 'include' ? labels : undefined,// eslint-disable-line camelcase
profile: {
options: {
filename: filename + extension,
contentType: 'application/octet-stream'
},
value: profileContents,
}
};
await sails.helpers.http.sendHttpRequest.with({
method: 'POST',
baseUrl: sails.config.custom.fleetBaseUrl,
url: `/api/v1/fleet/configuration_profiles?team_id=${teamApid}`,
enctype: 'multipart/form-data',
body: {
team_id: teamApid,// eslint-disable-line camelcase
profile: {
options: {
filename: filename + extension,
contentType: 'application/octet-stream'
},
value: profileContents,
}
},
body: bodyForThisRequest,
headers: {
Authorization: `Bearer ${sails.config.custom.fleetApiToken}`,
},
@ -152,22 +171,25 @@ module.exports = {
}
}
for(let teamApid of newTeamIds){
let bodyForThisRequest = {
team_id: teamApid,// eslint-disable-line camelcase
labels_exclude_any: labelTargetBehavior === 'exclude' ? labels : undefined,// eslint-disable-line camelcase
labels_include_all: labelTargetBehavior === 'include' ? labels : undefined,// eslint-disable-line camelcase
profile: {
options: {
filename: filename + extension,
contentType: 'application/octet-stream'
},
value: profileContents,
}
};
// console.log(`Adding ${profile.name} to team id ${teamApid}`);
await sails.helpers.http.sendHttpRequest.with({
method: 'POST',
baseUrl: sails.config.custom.fleetBaseUrl,
url: `/api/v1/fleet/configuration_profiles?team_id=${teamApid}`,
enctype: 'multipart/form-data',
body: {
team_id: teamApid,// eslint-disable-line camelcase
profile: {
options: {
filename: filename + extension,
contentType: 'application/octet-stream'
},
value: profileContents,
}
},
body: bodyForThisRequest,
headers: {
Authorization: `Bearer ${sails.config.custom.fleetApiToken}`,
},
@ -187,12 +209,26 @@ module.exports = {
platform: extension === '.xml' ? 'windows' : 'darwin',
profileContents,
profileType: extension,
labels,
labelTargetBehavior,
profileTarget,
});
} else if(profile.id && newProfile){
// If there is a new profile that is replacing a database record, update the profileContents in the database.
// console.log('Updating existing undeployed profile!');
await UndeployedProfile.updateOne({id: profile.id}).set({
profileContents,
labels,
labelTargetBehavior,
profileTarget,
});
} else if(profile.id && labels) {
// Update label target behavior for undeployed profiles.
await UndeployedProfile.updateOne({id: profile.id}).set({
profileContents,
labels,
labelTargetBehavior,
profileTarget,
});
}
// All done.

View file

@ -17,6 +17,20 @@ module.exports = {
teams: {
type: ['string'],
description: 'An array of team IDs that this profile will be added to'
},
profileTarget: {
type: 'string',
description: 'The target for this configuration profile',
defaultsTo: 'all',
isIn: ['all', 'custom'],
},
labelTargetBehavior: {
type: 'string',
isIn: ['include', 'exclude'],
},
labels: {
type: ['string'],
description: 'A list of the names of labels that will be included/excluded.'
}
},
@ -40,7 +54,7 @@ module.exports = {
},
fn: async function ({newProfile, teams}) {
fn: async function ({newProfile, teams, profileTarget, labelTargetBehavior, labels}) {
let util = require('util');
let profile = await sails.reservoir(newProfile)
.intercept('E_EXCEEDS_UPLOAD_LIMIT', 'tooBig')
@ -63,6 +77,9 @@ module.exports = {
platform: profilePlatform,
profileType: extension,
createdAt: Date.now(),
profileTarget,
labels,
labelTargetBehavior,
};
if(!teams) {
newProfileInfo.profileContents = profileContents;
@ -70,21 +87,24 @@ module.exports = {
} else {
let newTeams = [];
for(let teamApid of teams){
let bodyForThisRequest = {
team_id: teamApid,// eslint-disable-line camelcase
labels_exclude_any: labelTargetBehavior === 'exclude' ? labels : undefined,// eslint-disable-line camelcase
labels_include_all: labelTargetBehavior === 'include' ? labels : undefined,// eslint-disable-line camelcase
profile: {
options: {
filename: profileFileName,
contentType: 'application/octet-stream'
},
value: profileContents,
}
};
let newProfileResponse = await sails.helpers.http.sendHttpRequest.with({
method: 'POST',
baseUrl: sails.config.custom.fleetBaseUrl,
url: `/api/v1/fleet/configuration_profiles?team_id=${teamApid}`,
enctype: 'multipart/form-data',
body: {
team_id: teamApid,// eslint-disable-line camelcase
profile: {
options: {
filename: profileFileName,
contentType: 'application/octet-stream'
},
value: profileContents,
}
},
body: bodyForThisRequest,
headers: {
Authorization: `Bearer ${sails.config.custom.fleetApiToken}`,
},

View file

@ -71,42 +71,55 @@ module.exports = {
})
.timeout(120000)
.retry(['requestFailed', {name: 'TimeoutError'}]);
let profilesForThisTeam = noTeamConfigurationProfilesResponseData.profiles;
allProfiles = allProfiles.concat(profilesForThisTeam);
let profilesForNoTeam = noTeamConfigurationProfilesResponseData.profiles;
allProfiles = allProfiles.concat(profilesForNoTeam);
let profilesInformation = [];
// Group configuration profiles by their identifier.
let allProfilesByIdentifier = _.groupBy(allProfiles, 'identifier');
for(let profileIdentifier in allProfilesByIdentifier) {
// Iterate through the arrays of profiles with the same unique identifier.
let teamsForThisProfile = [];
// Add the profile's UUID and information about the team this profile is assigned to the teams array for profiles.
for(let profile of allProfilesByIdentifier[profileIdentifier]) {
let informationAboutThisProfile = {
for(let profile of allProfiles) {
let profileInformation = {
name: profile.name,
identifier: profile.identifier,
platform: profile.platform,
createdAt: new Date(profile.created_at).getTime(),
team: {
uuid: profile.profile_uuid,
fleetApid: profile.team_id,
teamName: _.find(teams, {fleetApid: profile.team_id}).teamName,
};
teamsForThisProfile.push(informationAboutThisProfile);
}
let profile = allProfilesByIdentifier[profileIdentifier][0];// Grab the first profile returned in the api repsonse to build our profile configuration.
let profileInformation = {
name: profile.name,
identifier: profileIdentifier,
platform: profile.platform,
createdAt: new Date(profile.created_at).getTime(),
teams: teamsForThisProfile
},
profileTarget: 'all',
};
if(profile.labels_include_all) {
profileInformation.labels = _.pluck(profile.labels_include_all, 'name');
profileInformation.profileTarget = 'custom';
profileInformation.labelTargetBehavior = 'include';
} else if(profile.labels_exclude_any){
profileInformation.labels = _.pluck(profile.labels_exclude_any, 'name');
profileInformation.profileTarget = 'custom';
profileInformation.labelTargetBehavior = 'exclude';
}
profilesInformation.push(profileInformation);
}
// Group the profiles based on identifier, labels, and labelTargetBehavior
let profilesGroupedbyLabelsAndIdentifier = _.groupBy(profilesInformation, (profile)=>{
return `${profile.identifier}|${JSON.stringify(profile.labels)}|${profile.labelTargetBehavior}`;
});
// map the grouped profiles and merge profiles that have the same labels, target behavior, and identifier.
let allProfilesOnFleetInstance = Object.values(profilesGroupedbyLabelsAndIdentifier).map(profileGroup => {
return {
...profileGroup[0],// expand the first item in the profileGroup
teams: profileGroup.map(item => item.team)// Merge the teams arrays
};
});
// Get the undeployed profiles from the app's database.
let undeployedProfiles = await UndeployedProfile.find();
profilesInformation = _.union(profilesInformation, undeployedProfiles);
allProfilesOnFleetInstance = _.union(allProfilesOnFleetInstance, undeployedProfiles);
// Sort profiles by their name.
profilesInformation = _.sortByOrder(profilesInformation, 'name', 'asc');
allProfilesOnFleetInstance = _.sortByOrder(allProfilesOnFleetInstance, 'name', 'asc');
// Respond with view.
return {profiles: profilesInformation, teams};
return {profiles: allProfilesOnFleetInstance, teams};
}

View file

@ -46,6 +46,26 @@ module.exports = {
},
labels: {
type: 'json',
example: ['All hosts', 'Linux hosts'],
description: 'A list of the Fleet API IDs of labels this profile is associated with (if any).',
},
labelTargetBehavior: {
type: 'string',
description: 'Whether to exclude or include hosts with the labels in the labels attribute when assigning this profile.',
isIn: ['exclude', 'include'],
defaultsTo: 'include',
},
profileTarget: {
type: 'string',
description: 'What hosts will be targetted when this profile is deployed. "all" for all hosts, or "custom" if a profile targets hosts by labels',
isIn: ['all', 'custom'],
defaultsTo: 'all',
},
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
// ║╣ ║║║╠╩╗║╣ ║║╚═╗
// ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝

Binary file not shown.

After

Width:  |  Height:  |  Size: 713 B

View file

@ -13,7 +13,7 @@
Cloud.setup({
/* eslint-disable */
methods: {"confirmEmail":{"verb":"GET","url":"/email/confirm","args":["token"]},"logout":{"verb":"GET","url":"/api/v1/account/logout","args":[]},"updatePassword":{"verb":"PUT","url":"/api/v1/account/update-password","args":["password"]},"updateProfile":{"verb":"PUT","url":"/api/v1/account/update-profile","args":["fullName","emailAddress"]},"login":{"verb":"PUT","url":"/api/v1/entrance/login","args":["emailAddress","password","rememberMe"]},"sendPasswordRecoveryEmail":{"verb":"POST","url":"/api/v1/entrance/send-password-recovery-email","args":["emailAddress"]},"updatePasswordAndLogin":{"verb":"POST","url":"/api/v1/entrance/update-password-and-login","args":["password","token"]},"deleteProfile":{"verb":"POST","url":"/api/v1/delete-profile","args":["profile"]},"downloadProfile":{"verb":"GET","url":"/download-profile","args":["id","uuid"]},"uploadProfile":{"verb":"POST","url":"/api/v1/upload-profile","args":["newProfile","teams"]},"editProfile":{"verb":"POST","url":"/api/v1/edit-profile","args":["profile","newTeamIds","newProfile"]},"getProfiles":{"verb":"GET","url":"/api/v1/get-profiles","args":[]},"getScripts":{"verb":"GET","url":"/api/v1/get-scripts","args":[]},"deleteScript":{"verb":"POST","url":"/api/v1/delete-script","args":["script"]},"downloadScript":{"verb":"GET","url":"/download-script","args":["fleetApid","id"]},"uploadScript":{"verb":"POST","url":"/api/v1/upload-script","args":["newScript","teams"]},"editScript":{"verb":"POST","url":"/api/v1/edit-script","args":["script","newTeamIds","newScript"]},"getSoftware":{"verb":"GET","url":"/api/v1/get-software","args":[]},"downloadSoftware":{"verb":"GET","url":"/download-software","args":["id","fleetApid","teamApid"]},"deleteSoftware":{"verb":"POST","url":"/api/v1/software/delete-software","args":["software"]},"editSoftware":{"verb":"POST","url":"/api/v1/software/edit-software","args":["newSoftware","newTeamIds","software","preInstallQuery","installScript","postInstallScript","uninstallScript"]},"uploadSoftware":{"verb":"POST","url":"/api/v1/software/upload-software","args":["newSoftware","teams"]}}
methods: {"confirmEmail":{"verb":"GET","url":"/email/confirm","args":["token"]},"logout":{"verb":"GET","url":"/api/v1/account/logout","args":[]},"updatePassword":{"verb":"PUT","url":"/api/v1/account/update-password","args":["password"]},"updateProfile":{"verb":"PUT","url":"/api/v1/account/update-profile","args":["fullName","emailAddress"]},"login":{"verb":"PUT","url":"/api/v1/entrance/login","args":["emailAddress","password","rememberMe"]},"sendPasswordRecoveryEmail":{"verb":"POST","url":"/api/v1/entrance/send-password-recovery-email","args":["emailAddress"]},"updatePasswordAndLogin":{"verb":"POST","url":"/api/v1/entrance/update-password-and-login","args":["password","token"]},"deleteProfile":{"verb":"POST","url":"/api/v1/delete-profile","args":["profile"]},"downloadProfile":{"verb":"GET","url":"/download-profile","args":["id","uuid"]},"uploadProfile":{"verb":"POST","url":"/api/v1/upload-profile","args":["newProfile","teams","profileTarget","labelTargetBehavior","labels"]},"editProfile":{"verb":"POST","url":"/api/v1/edit-profile","args":["profile","newTeamIds","newProfile","profileTarget","labelTargetBehavior","labels"]},"getProfiles":{"verb":"GET","url":"/api/v1/get-profiles","args":[]},"getScripts":{"verb":"GET","url":"/api/v1/get-scripts","args":[]},"deleteScript":{"verb":"POST","url":"/api/v1/delete-script","args":["script"]},"downloadScript":{"verb":"GET","url":"/download-script","args":["fleetApid","id"]},"uploadScript":{"verb":"POST","url":"/api/v1/upload-script","args":["newScript","teams"]},"editScript":{"verb":"POST","url":"/api/v1/edit-script","args":["script","newTeamIds","newScript"]},"getSoftware":{"verb":"GET","url":"/api/v1/get-software","args":[]},"downloadSoftware":{"verb":"GET","url":"/download-software","args":["id","fleetApid","teamApid"]},"deleteSoftware":{"verb":"POST","url":"/api/v1/software/delete-software","args":["software"]},"editSoftware":{"verb":"POST","url":"/api/v1/software/edit-software","args":["newSoftware","newTeamIds","software","preInstallQuery","installScript","postInstallScript","uninstallScript"]},"uploadSoftware":{"verb":"POST","url":"/api/v1/software/upload-software","args":["newSoftware","teams"]},"getLabels":{"verb":"GET","url":"/api/v1/get-labels","args":[]}}
/* eslint-enable */
});

View file

@ -42,8 +42,8 @@ parasails.registerComponent('cloud-error', {
// ╠═╣ ║ ║║║║
// ╩ ╩ ╩ ╩ ╩╩═╝
template: `
<div>
<p :class="{ 'm-0': beWithoutMargins }" class="text-danger"><slot name="default">An unexpected error occurred communicating with the Fleet API</slot></p>
<div class="d-flex flex-row align-items-center">
<img src="/images/Icon-error-16x16@2x.png" class="d-block mr-2" style="height: 16px;"><p :class="{ 'm-0': beWithoutMargins }" class="text-danger"><slot name="default"> An unexpected error occurred communicating with the Fleet API</slot></p>
</div>
`,

View file

@ -51,33 +51,44 @@ parasails.registerComponent('multifield', {
// ╩ ╩ ╩ ╩ ╩╩═╝
template: `
<div class="multifield-set">
<div class="multifield-item" v-for="(unused,idx) in currentFieldValues" :key="idx" :role="'item-'+idx">
<!-- <span class="multifield-item-label">{{idx+1}}.</span> -->
<slot name="item-field" :item="currentFieldValues[idx]" :do-set="_getCurriedDoSetFn(idx)" :all-items="currentFieldValues" :idx="idx">
<input type="text" :placeholder="inputPlaceholder" :class="[cloudError && _.contains(cloudError.responseInfo.data, currentFieldValues[idx]) ? 'text-danger is-invalid' : '']" :value.sync="currentFieldValues[idx]" @input="inputDefaultItemField($event, idx)" role="focusable" v-if="!inputType"/>
<select class="custom-select" :value.sync="currentFieldValues[idx]" @input="inputDefaultItemField($event, idx)" role="focusable" v-else-if="inputType && inputType === 'nameAndHostCountSelect'">
<option :value="undefined" selected>---</option>
<option v-for="option in optionsForSelect" :value="option.id">{{option.name}} ({{option.hostCount}} {{option.hostCount > 1 || option.hostCount === 0 ? 'hosts' : 'host'}})</option>
</select>
<select class="custom-select" :value.sync="currentFieldValues[idx]" @input="inputDefaultItemField($event, idx)" role="focusable" v-else-if="inputType && inputType === 'select'">
<option :value="undefined" selected>---</option>
<option v-for="option in optionsForSelect" :value="option.fleetApid">{{option.name}}</option>
</select>
<select class="custom-select" :value.sync="currentFieldValues[idx]" @input="inputTeamSelectItemField($event, idx)" role="focusable" v-else-if="inputType && inputType === 'teamSelect'">
<option :value="undefined" selected>---</option>
<option value="allTeams">All teams</option>
<option v-for="option in optionsForSelect" :value="option.fleetApid">{{option.teamName}}</option>
</select>
<input :type="inputType" :placeholder="inputPlaceholder" :value.sync="currentFieldValues[idx]" @input="inputDefaultItemField($event, idx)" role="focusable" v-else-if="inputType">
</slot>
<button class="multifield-item-remove-button" type="button" v-if="currentFieldValues.length >= 2" @click="clickRemoveItem(idx)"></button>
<button class="multifield-item-remove-button" type="button" v-else-if="currentFieldValues.length === 1 && currentFieldValues[0] !== undefined" @click="clickResetSingleItem()"></button>
<div v-if="inputType === 'checkboxes'">
<div class="d-flex flex-wrap flex-row">
<div v-for="option in optionsForSelect" :key="option.name" class="form-check mr-3 mb-3">
<input type="checkbox" :value="option.name" :id="'checkbox-' + option.name" class="form-check-input" @change="inputCheckboxItemField($event)" :checked="_.contains(currentFieldValues, option.name)"/>
<label :for="'checkbox-' + option.name" class="form-check-label">{{ option.name }}</label>
</div>
</div>
</div>
<div class="add-button-wrapper d-flex flex-row justify-content-start" :class="_.all(currentFieldValues, (item)=> item !== undefined) ? '' : 'empty'">
<a class="add-button" @click="clickAddItem()" v-if="_.all(currentFieldValues, (item)=> item !== undefined)"><strong>+</strong>&nbsp;&nbsp;{{addButtonText || 'Add another'}}</a>
<span v-else>&nbsp;</span>
<div v-else>
<div class="multifield-item" v-for="(unused,idx) in currentFieldValues" :key="idx" :role="'item-'+idx">
<!-- <span class="multifield-item-label">{{idx+1}}.</span> -->
<slot name="item-field" :item="currentFieldValues[idx]" :do-set="_getCurriedDoSetFn(idx)" :all-items="currentFieldValues" :idx="idx">
<input type="text" :placeholder="inputPlaceholder" :class="[cloudError && _.contains(cloudError.responseInfo.data, currentFieldValues[idx]) ? 'text-danger is-invalid' : '']" :value.sync="currentFieldValues[idx]" @input="inputDefaultItemField($event, idx)" role="focusable" v-if="!inputType"/>
<select class="custom-select" :value.sync="currentFieldValues[idx]" @input="inputDefaultItemField($event, idx)" role="focusable" v-else-if="inputType && inputType === 'nameAndHostCountSelect'">
<option :value="undefined" selected>---</option>
<option v-for="option in optionsForSelect" :value="option.id">{{option.name}} ({{option.hostCount}} {{option.hostCount > 1 || option.hostCount === 0 ? 'hosts' : 'host'}})</option>
</select>
<select class="custom-select" :value.sync="currentFieldValues[idx]" @input="inputDefaultItemField($event, idx)" role="focusable" v-else-if="inputType && inputType === 'select'">
<option :value="undefined" selected>---</option>
<option v-for="option in optionsForSelect" :value="option.fleetApid">{{option.name}}</option>
</select>
<select class="custom-select" :value.sync="currentFieldValues[idx]" @input="inputTeamSelectItemField($event, idx)" role="focusable" v-else-if="inputType && inputType === 'teamSelect'">
<option :value="undefined" selected>---</option>
<option value="allTeams">All teams</option>
<option v-for="option in optionsForSelect" :value="option.fleetApid">{{option.teamName}}</option>
</select>
<input :type="inputType" :placeholder="inputPlaceholder" :value.sync="currentFieldValues[idx]" @input="inputDefaultItemField($event, idx)" role="focusable" v-else-if="inputType">
</slot>
<button class="multifield-item-remove-button" type="button" v-if="currentFieldValues.length >= 2" @click="clickRemoveItem(idx)"></button>
<button class="multifield-item-remove-button" type="button" v-else-if="currentFieldValues.length === 1 && currentFieldValues[0] !== undefined" @click="clickResetSingleItem()"></button>
</div>
<div class="add-button-wrapper d-flex flex-row justify-content-start" :class="_.all(currentFieldValues, (item)=> item !== undefined) ? '' : 'empty'">
<a class="add-button" @click="clickAddItem()" v-if="_.all(currentFieldValues, (item)=> item !== undefined)"><strong>+</strong>&nbsp;&nbsp;{{addButtonText || 'Add another'}}</a>
<span v-else>&nbsp;</span>
</div>
</div>
</div>
`,
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
@ -88,7 +99,6 @@ parasails.registerComponent('multifield', {
if (this.value !== undefined && !_.isArray(this.value)) {
throw new Error('In <multifield>, if specified, `v-model`/`:value` must be either an array or `undefined`. But instead, got: '+this.value);
}//•
if (this.value === undefined || _.isEqual(this.value, [])) {
this.currentFieldValues = [ undefined ];
} else {
@ -146,6 +156,26 @@ parasails.registerComponent('multifield', {
this.optionsForSelect = _.clone(this.selectOptions);
}
}
if(this.inputType === 'checkboxes') {
if(!_.isArray(this.selectOptions)){
throw new Error('Missing selectOptions. When using inputType="select", an array of selectOptions is required.');
} else {
for(let option of this.selectOptions){
// If we're using inputType="select", we will validate all options before cloning the object.
if(!option.value){
throw new Error(`Option in selectOptions is missing a value. When using inputType="select", A value property is required for all objects in the selectOptions array. Object missing a value. ${option}`);
}
if(!option.name){
throw new Error(`Option in selectOptions is missing a name. When using inputType="select", A name property is required for all objects in the selectOptions array. Object missing a name. ${option}`);
}
}
this.optionsForSelect = _.clone(this.selectOptions);
if(this.currentFieldValues === [null]){
this.currentFieldValues = [];
}
}
}
if(this.placeholder){
this.inputPlaceholder = this.placeholder;
}
@ -196,6 +226,20 @@ parasails.registerComponent('multifield', {
this._handleChangingFieldValues();
},
inputCheckboxItemField: async function($event) {
let checkboxValue = $event.target.value;
if($event.target.checked) {
this.currentFieldValues.push(checkboxValue);
} else {
this.currentFieldValues = this.currentFieldValues
.filter(value => value !== checkboxValue);
}
this.currentFieldValues = this.currentFieldValues.filter(value => value !== undefined && value !== null);
await this.forceRender();
this._handleChangingFieldValues();
},
clickAddItem: async function() {
this.currentFieldValues.push(undefined);
await this.forceRender();//«« this is so that the programmatic focusing code below will work

View file

@ -55,13 +55,13 @@ parasails.registerPage('profiles', {
if(this.teamFilter !== undefined){
this.selectedTeam = _.find(this.teams, {fleetApid: this.teamFilter});
let profilesOnThisTeam = _.filter(this.profiles, (profile)=>{
// console.log(profile.profiles);
return profile.teams && _.where(profile.teams, {'fleetApid': this.selectedTeam.fleetApid}).length > 0;
});
this.profilesToDisplay = profilesOnThisTeam;
} else {
this.profilesToDisplay = this.profiles;
}
await this.forceRender();
},
clickChangeTeamFilter: async function(teamApid) {// Used by the tooltip links.
this.teamFilter = teamApid;
@ -79,22 +79,35 @@ parasails.registerPage('profiles', {
}
},
clickOpenEditModal: async function(profile) {
this.profileToEdit = _.clone(profile);
this.formData.newTeamIds = _.pluck(this.profileToEdit.teams, 'fleetApid');
this.formData.profile = profile;
this.profileToEdit = _.cloneDeep(profile);
console.log(this.profileToEdit);
this.formData = {
profile: _.clone(this.profileToEdit),
newTeamIds: _.pluck(this.profileToEdit.teams, 'fleetApid'),
profileTarget: this.profileToEdit.profileTarget === 'custom' ? 'custom' : 'all',
labelTargetBehavior: this.profileToEdit.labelTargetBehavior ? this.profileToEdit.labelTargetBehavior : 'include',
labels: this.profileToEdit.labels ? this.profileToEdit.labels : [],
};
console.log(this.formData);
this.modal = 'edit-profile';
await this._getLabels();
},
clickOpenDeleteModal: async function(profile) {
this.formData.profile = _.clone(profile);
this.modal = 'delete-profile';
},
clickOpenAddProfileModal: async function() {
this.$set(this.formData, 'profileTarget', 'all');
this.$set(this.formData, 'labels', []);
this.$set(this.formData, 'labelTargetBehavior', 'include');
this.modal = 'add-profile';
await this._getLabels();
},
closeModal: async function() {
this.modal = '';
this.formErrors = {};
this.formData = {};
this.cloudError = '';
await this.forceRender();
},
submittedForm: async function() {
@ -108,7 +121,13 @@ parasails.registerPage('profiles', {
},
handleSubmittingAddProfileForm: async function() {
let argins = _.clone(this.formData);
await Cloud.uploadProfile.with({newProfile: argins.newProfile, teams: argins.teams});
await Cloud.uploadProfile.with({
newProfile: argins.newProfile,
teams: argins.teams,
profileTarget: argins.profileTarget,
labels: argins.profileTarget !== 'all' ? argins.labels : [],
labelTargetBehavior: argins.profileTarget !== 'all' ? argins.labelTargetBehavior : undefined,
});
await this._getProfiles();
},
handleSubmittingEditProfileForm: async function() {
@ -116,7 +135,22 @@ parasails.registerPage('profiles', {
if(argins.newTeamIds === [undefined]){
argins.newTeamIds = [];
}
await Cloud.editProfile.with({profile: argins.profile, newProfile: argins.newProfile, newTeamIds: argins.newTeamIds});
if(argins.profileTarget === 'custom'){
await Cloud.editProfile.with({
profile: argins.profile,
newTeamIds: argins.newTeamIds,
newProfile: argins.newProfile,
labels: argins.labels,
profileTarget: argins.profileTarget,
labelTargetBehavior: argins.labelTargetBehavior,
});
} else {
await Cloud.editProfile.with({
profile: argins.profile,
newTeamIds: argins.newTeamIds,
newProfile: argins.newProfile
});
}
await this._getProfiles();
},
_getProfiles: async function() {
@ -126,6 +160,19 @@ parasails.registerPage('profiles', {
this.profiles = newProfilesInformation;
this.overlaySyncing = false;
await this.changeTeamFilter();
},
_getLabels: async function() {
this.syncing = true;
this.labelsSyncing = true;
this.labels = await Cloud.getLabels().tolerate((err)=>{
this.cloudError = err;
this.syncing = false;
});
if(!this.cloudError){
this.labelsSyncing = false;
this.syncing = false;
}
}
}
});

View file

@ -213,6 +213,83 @@
margin-right: 8px;
}
}
[purpose='form-option'] {
user-select: none;
cursor: pointer;
width: fit-content;
padding: 8px 12px 8px 8px;
margin-bottom: 16px;
display: flex;
flex-direction: row;
align-items: center;
border-radius: 7px;
border: 1px solid #E2E4EA;
font-size: 16px;
line-height: 24px;
color: #515774;
white-space: nowrap;
height: fit-content;
input {
cursor: pointer;
margin-right: 8px;
display: none;
}
[purpose='custom-radio'] {
margin-right: 8px;
display: flex;
min-width: 18px;
min-height: 18px;
border-radius: 50%;
border: 1px solid #E2E4EA;
justify-content: center;
align-items: center;
[purpose='custom-radio-selected'] {
min-width: 10px;
min-height: 10px;
border-radius: 50%;
background-color: @core-vibrant-blue;
transform: scale(0);
transition: 180ms transform ease-in-out;
}
}
input[type='radio']:checked + [purpose='custom-radio'] {
[purpose='custom-radio-selected'] {
transform: scale(1);
}
}
.form-control {
height: 40px;
}
&:hover {
border: 1px solid @core-vibrant-blue;
}
&.selected {
border: 1px solid @core-vibrant-blue;
}
}
.loading-dot {
opacity: 0;
display: inline;
color: #6A67FE;
font-size: 16px;
gap: 8px;
.fade-in();
.animation-duration(1s);
.animation-iteration-count(infinite);
.animation-direction(linear);
&.dot1 {
.animation-delay(0.25s);
}
&.dot2 {
.animation-delay(0.5s);
}
&.dot3 {
.animation-delay(0.75s);
}
&.dot4 {
.animation-delay(1s);
}
}
[purpose='delete-button'] {
border-radius: 6px;
background: #D66C7B;

View file

@ -67,4 +67,5 @@ module.exports.routes = {
'POST /api/v1/software/delete-software': { action: 'software/delete-software' },
'POST /api/v1/software/edit-software': { action: 'software/edit-software' },
'POST /api/v1/software/upload-software': { action: 'software/upload-software' },
'GET /api/v1/get-labels': { action: 'get-labels' },
};

View file

@ -85,23 +85,59 @@
<div class="pointer" @click="closeModal()">&times;</div>
</div>
<ajax-form :handle-submitting="handleSubmittingEditProfileForm" :syncing.sync="syncing" :cloud-error.sync="cloudError" :form-errors.sync="formErrors" :form-data="formData" :form-rules="editProfileFormRules" @submitted="submittedForm()">
<div purpose="profile-information" v-if="!formData.newProfile">
<div class="d-flex flex-row justify-content-start">
<img style="height: 40px; width: 34px;" alt="Configuration profile" src="/images/profile-34x40@2x.png">
<div class="d-flex flex-column">
<p><strong>{{profileToEdit.name}}</strong></p>
<p class="muted">{{platformFriendlyNames[profileToEdit.platform]}}</p>
<div v-if="labelsSyncing">
<div class="d-flex flex-row align-items-center justify-content-center my-4" >
<div v-if="!cloudError">
<span style="top: -4px; font-size: 16px; margin: 0 8px 0 0;" class="loading-dot dot1 position-relative"><span class="fa fa-circle"></span></span>
<span style="top: -4px; font-size: 16px; margin: 0 8px 0 0;" class="loading-dot dot2 position-relative"><span class="fa fa-circle"></span></span>
<span style="top: -4px; font-size: 16px; margin: 0 8px 0 0;" class="loading-dot dot3 position-relative"><span class="fa fa-circle"></span></span>
<span style="top: -4px; font-size: 16px; margin: 0 8px 0 0;" class="loading-dot dot4 position-relative"><span class="fa fa-circle"></span></span>
</div>
<cloud-error v-if="cloudError"></cloud-error>
</div>
</div>
<cloud-error v-if="cloudError === 'payloadIdentifierDoesNotMatch'"></cloud-error>
<file-upload id="edit-file-upload" mode="profiles" :disabled="syncing" accept=".xml,.mobileconfig" v-model="formData.newProfile"></file-upload>
<div purpose="teams-picker">
<p class="mb-2"><strong>Teams</strong></p>
<multifield :value="formData.teams" v-model="formData.newTeamIds" input-type="teamSelect" :select-options="teams" add-button-text="Add team"></multifield>
<div v-else>
<div purpose="profile-information" v-if="!formData.newProfile">
<div class="d-flex flex-row justify-content-start">
<img style="height: 40px; width: 34px;" alt="Configuration profile" src="/images/profile-34x40@2x.png">
<div class="d-flex flex-column">
<p><strong>{{profileToEdit.name}}</strong></p>
<p class="muted">{{platformFriendlyNames[profileToEdit.platform]}}</p>
</div>
</div>
</div>
<cloud-error v-if="cloudError === 'payloadIdentifierDoesNotMatch'"></cloud-error>
<file-upload id="edit-file-upload" mode="profiles" :disabled="syncing" accept=".xml,.mobileconfig" v-model="formData.newProfile"></file-upload>
<div purpose="teams-picker">
<p class="mb-2"><strong>Teams</strong></p>
<multifield :value="formData.teams" v-model="formData.newTeamIds" input-type="teamSelect" :select-options="teams" add-button-text="Add team"></multifield>
</div>
<div>
<p><strong>Target</strong></p>
<div class="form-group">
<label purpose="form-option" class="form-control border-0 mb-2 p-0 h-auto" :class="[formData.profileTarget === 'all' ? 'selected' : '']">
<input type="radio" :class="[formErrors.profileTarget ? 'is-invalid' : '']" v-model.trim="formData.profileTarget" value="all">
<span purpose="custom-radio"><span purpose="custom-radio-selected"></span></span>
All hosts on selected teams
</label>
<label purpose="form-option" class="form-control border-0 mb-0 p-0" :class="[formData.profileTarget === 'custom' ? 'selected' : '']">
<input type="radio" :class="[formErrors.profileTarget ? 'is-invalid' : '']" v-model.trim="formData.profileTarget" value="custom">
<span purpose="custom-radio"><span purpose="custom-radio-selected"></span></span>
Custom
</label>
</div>
</div>
<div v-if="formData.profileTarget === 'custom'">
<select class="custom-select form-control mb-4" v-model="formData.labelTargetBehavior">
<option value="include" selected>Hosts with all of these labels</option>
<option value="exclude">Hosts with none of these labels</option>
</select>
<multifield :value="formData.labels" v-model="formData.labels" input-type="checkboxes" :select-options="labels"></multifield>
</div>
<cloud-error v-if="cloudError && cloudError !== 'payloadIdentifierDoesNotMatch'"></cloud-error>
</div>
<div purpose="modal-buttons" class="d-flex flex-row justify-content-end align-items-center">
<cloud-error v-if="cloudError && cloudError !== 'payloadIdentifierDoesNotMatch'"></cloud-error>
<ajax-button :syncing.sync="syncing" purpose="modal-button" type="submit">Save</ajax-button>
</div>
</ajax-form>
@ -134,15 +170,50 @@
<div class="pointer" @click="closeModal()">&times;</div>
</div>
<ajax-form :handle-submitting="handleSubmittingAddProfileForm" :syncing.sync="syncing" :cloud-error.sync="cloudError" :form-errors.sync="formErrors" :form-data="formData" :form-rules="addProfileFormRules" @submitted="submittedForm()">
<file-upload id="add-file-upload" mode="profiles" :class="[formErrors.newProfile ? 'is-invalid' : '']" :disabled="syncing" v-model="formData.newProfile">
</file-upload>
<div class="invalid-feedback text-center" v-if="formErrors.newProfile">Please upload a new profile.</div>
<div v-if="labelsSyncing">
<div class="d-flex flex-row align-items-center justify-content-center my-4" >
<div v-if="!cloudError">
<span style="top: -4px; font-size: 16px; margin: 0 8px 0 0;" class="loading-dot dot1 position-relative"><span class="fa fa-circle"></span></span>
<span style="top: -4px; font-size: 16px; margin: 0 8px 0 0;" class="loading-dot dot2 position-relative"><span class="fa fa-circle"></span></span>
<span style="top: -4px; font-size: 16px; margin: 0 8px 0 0;" class="loading-dot dot3 position-relative"><span class="fa fa-circle"></span></span>
<span style="top: -4px; font-size: 16px; margin: 0 8px 0 0;" class="loading-dot dot4 position-relative"><span class="fa fa-circle"></span></span>
</div>
<cloud-error v-if="cloudError"></cloud-error>
</div>
<div purpose="teams-picker" class="mt-4">
<p class="mb-2"><strong>Teams</strong></p>
<multifield :value="formData.teams" v-model="formData.teams" input-type="teamSelect" :select-options="teams" add-button-text="Add team"></multifield>
</div>
<cloud-error v-if="cloudError"></cloud-error>
<div v-else>
<file-upload id="add-file-upload" mode="profiles" :class="[formErrors.newProfile ? 'is-invalid' : '']" :disabled="syncing" v-model="formData.newProfile">
</file-upload>
<div class="invalid-feedback text-center" v-if="formErrors.newProfile">Please upload a new profile.</div>
<div purpose="teams-picker" class="mt-4">
<p class="mb-2"><strong>Teams</strong></p>
<multifield :value="formData.teams" v-model="formData.teams" input-type="teamSelect" :select-options="teams" add-button-text="Add team"></multifield>
</div>
<div class="form-group">
<p><strong>Target</strong></p>
<label purpose="form-option" class="form-control border-0 mb-2 p-0 h-auto">
<input type="radio" :class="[formErrors.profileTarget ? 'is-invalid' : '']" v-model.trim="formData.profileTarget" value="all">
<span purpose="custom-radio"><span purpose="custom-radio-selected"></span></span>
All hosts on selected teams
</label>
<label purpose="form-option" class="form-control border-0 mb-0 p-0">
<input type="radio" :class="[formErrors.profileTarget ? 'is-invalid' : '']" v-model.trim="formData.profileTarget" value="custom">
<span purpose="custom-radio"><span purpose="custom-radio-selected"></span></span>
Custom
</label>
</div>
<div v-if="formData.profileTarget === 'custom'">
<select class="custom-select form-control mb-4" v-model="formData.labelTargetBehavior">
<option value="include" selected>Hosts with all of these labels</option>
<option value="exclude">Hosts with none of these labels</option>
</select>
<multifield :value="formData.labels" v-model="formData.labels" input-type="checkboxes" :select-options="labels"></multifield>
</div>
<cloud-error v-if="cloudError"></cloud-error>
</div>
<div purpose="modal-buttons" class="d-flex flex-row justify-content-end align-items-center">
<a purpose="cancel-button" @click="closeModal()">Cancel</a>
<ajax-button :syncing.sync="syncing" purpose="modal-button" :disabled="!formData.newProfile" type="submit">Add</ajax-button>