fix: vGPU and USB enable/disable actions needs to be hidden for read only users (#877)

* fix: vGPU / USB enable/disable actions needs to be hidden for read only users

Signed-off-by: Andy Lee <andy.lee@suse.com>

* style: add scoped styles for group actions in DeviceList component

Signed-off-by: Andy Lee <andy.lee@suse.com>

* style: remove width property from group actions in DeviceList components

Signed-off-by: Andy Lee <andy.lee@suse.com>

* fix: update logging type in init function and improve SideNav visibility handling

Signed-off-by: Andy Lee <andy.lee@suse.com>

* fix: ensure canManageGroup only returns true for non-empty rows with updatable permissions

Signed-off-by: Andy Lee <andy.lee@suse.com>

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
This commit is contained in:
Andy Lee 2026-05-22 11:56:44 +08:00 committed by GitHub
parent 09e8946cc3
commit 91232beffc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 129 additions and 57 deletions

View File

@ -237,6 +237,7 @@ export function init($plugin, store) {
labelKey: 'harvester.addons.vmImport.labels.vmimport', labelKey: 'harvester.addons.vmImport.labels.vmimport',
group: 'vmimport', group: 'vmimport',
namespaced: true, namespaced: true,
ifHaveType: HCI.VMIMPORT,
route: { route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`, name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT } params: { resource: HCI.VMIMPORT }
@ -266,6 +267,7 @@ export function init($plugin, store) {
labelKey: 'harvester.addons.vmImport.labels.vmimportSourceVMWare', labelKey: 'harvester.addons.vmImport.labels.vmimportSourceVMWare',
group: 'vmimport', group: 'vmimport',
namespaced: true, namespaced: true,
ifHaveType: HCI.VMIMPORT_SOURCE_V,
route: { route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`, name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT_SOURCE_V } params: { resource: HCI.VMIMPORT_SOURCE_V }
@ -295,6 +297,7 @@ export function init($plugin, store) {
labelKey: 'harvester.addons.vmImport.labels.vmimportSourceOpenStack', labelKey: 'harvester.addons.vmImport.labels.vmimportSourceOpenStack',
group: 'vmimport', group: 'vmimport',
namespaced: true, namespaced: true,
ifHaveType: HCI.VMIMPORT_SOURCE_O,
route: { route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`, name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT_SOURCE_O } params: { resource: HCI.VMIMPORT_SOURCE_O }
@ -323,6 +326,7 @@ export function init($plugin, store) {
labelKey: 'harvester.addons.vmImport.labels.vmimportSourceOVA', labelKey: 'harvester.addons.vmImport.labels.vmimportSourceOVA',
group: 'vmimport', group: 'vmimport',
namespaced: true, namespaced: true,
ifHaveType: HCI.VMIMPORT_SOURCE_OVA,
route: { route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`, name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT_SOURCE_OVA } params: { resource: HCI.VMIMPORT_SOURCE_OVA }
@ -484,6 +488,7 @@ export function init($plugin, store) {
}); });
virtualType({ virtualType({
ifHaveType: LOGGING.CLUSTER_FLOW,
labelKey: 'harvester.logging.clusterFlow.label', labelKey: 'harvester.logging.clusterFlow.label',
name: HCI.CLUSTER_FLOW, name: HCI.CLUSTER_FLOW,
namespaced: true, namespaced: true,
@ -507,6 +512,7 @@ export function init($plugin, store) {
}); });
virtualType({ virtualType({
ifHaveType: LOGGING.CLUSTER_OUTPUT,
labelKey: 'harvester.logging.clusterOutput.label', labelKey: 'harvester.logging.clusterOutput.label',
name: HCI.CLUSTER_OUTPUT, name: HCI.CLUSTER_OUTPUT,
namespaced: true, namespaced: true,
@ -530,6 +536,7 @@ export function init($plugin, store) {
}); });
virtualType({ virtualType({
ifHaveType: LOGGING.FLOW,
labelKey: 'harvester.logging.flow.label', labelKey: 'harvester.logging.flow.label',
name: HCI.FLOW, name: HCI.FLOW,
namespaced: true, namespaced: true,
@ -553,6 +560,7 @@ export function init($plugin, store) {
}); });
virtualType({ virtualType({
ifHaveType: LOGGING.OUTPUT,
labelKey: 'harvester.logging.output.label', labelKey: 'harvester.logging.output.label',
name: HCI.OUTPUT, name: HCI.OUTPUT,
namespaced: true, namespaced: true,

View File

@ -152,6 +152,10 @@ export default {
return !rows.find((device) => !device.passthroughClaim); return !rows.find((device) => !device.passthroughClaim);
}, },
canManageGroup(rows = []) {
return rows.length > 0 && rows.every((row) => row.canUpdate === true);
},
changeRows(filterRows, parentSriov) { changeRows(filterRows, parentSriov) {
this['filterRows'] = filterRows; this['filterRows'] = filterRows;
this['parentSriov'] = parentSriov; this['parentSriov'] = parentSriov;
@ -185,22 +189,27 @@ export default {
v-trim-whitespace v-trim-whitespace
class="group-tab" class="group-tab"
> >
<button <div
v-if="groupIsAllEnabled(group.rows)" v-if="canManageGroup(group.rows)"
type="button" class="group-actions"
class="btn btn-sm role-secondary mr-5"
@click="e=>{disableGroup(group.rows); e.target.blur()}"
> >
{{ t('harvester.pci.disableGroup') }} <button
</button> v-if="groupIsAllEnabled(group.rows)"
<button type="button"
v-else class="btn btn-sm role-secondary mr-5"
type="button" @click="e=>{disableGroup(group.rows); e.target.blur()}"
class="btn btn-sm role-secondary mr-5" >
@click="e=>{enableGroup(group.rows); e.target.blur()}" {{ t('harvester.pci.disableGroup') }}
> </button>
{{ t('harvester.pci.enableGroup') }} <button
</button> v-else
type="button"
class="btn btn-sm role-secondary mr-5"
@click="e=>{enableGroup(group.rows); e.target.blur()}"
>
{{ t('harvester.pci.enableGroup') }}
</button>
</div>
<span v-clean-html="group.key" /> <span v-clean-html="group.key" />
</div> </div>
</template> </template>
@ -232,3 +241,9 @@ export default {
</template> </template>
</ResourceTable> </ResourceTable>
</template> </template>
<style lang="scss" scoped>
.group-actions {
display: inline;
}
</style>

View File

@ -113,6 +113,11 @@ export default {
} }
}); });
}, },
canManageGroup(rows = []) {
return rows.every((row) => row.canUpdate === true);
},
groupIsAllEnabled(rows = []) { groupIsAllEnabled(rows = []) {
return !rows.find((device) => !device.passthroughClaim); return !rows.find((device) => !device.passthroughClaim);
}, },
@ -153,22 +158,27 @@ export default {
v-trim-whitespace v-trim-whitespace
class="group-tab" class="group-tab"
> >
<button <div
v-if="groupIsAllEnabled(group.rows)" v-if="canManageGroup(group.rows)"
type="button" class="group-actions"
class="btn btn-sm role-secondary mr-5"
@click="e=>{disableGroup(group.rows); e.target.blur()}"
> >
{{ t('harvester.usb.disableGroup') }} <button
</button> v-if="groupIsAllEnabled(group.rows)"
<button type="button"
v-else class="btn btn-sm role-secondary mr-5"
type="button" @click="e=>{disableGroup(group.rows); e.target.blur()}"
class="btn btn-sm role-secondary mr-5" >
@click="e=>{enableGroup(group.rows); e.target.blur()}" {{ t('harvester.usb.disableGroup') }}
> </button>
{{ t('harvester.usb.enableGroup') }} <button
</button> v-else
type="button"
class="btn btn-sm role-secondary mr-5"
@click="e=>{enableGroup(group.rows); e.target.blur()}"
>
{{ t('harvester.usb.enableGroup') }}
</button>
</div>
<span v-clean-html="group.key" /> <span v-clean-html="group.key" />
</div> </div>
</template> </template>
@ -181,3 +191,9 @@ export default {
</template> </template>
</ResourceTable> </ResourceTable>
</template> </template>
<style lang="scss" scoped>
.group-actions {
display: inline;
}
</style>

View File

@ -9,6 +9,7 @@ import HarvesterResource from './harvester';
export default class MIGCONFIGURATION extends HarvesterResource { export default class MIGCONFIGURATION extends HarvesterResource {
get _availableActions() { get _availableActions() {
let out = super._availableActions; let out = super._availableActions;
const canUpdate = !!this.linkFor('update');
out = out.map((action) => { out = out.map((action) => {
if (action.action === 'showConfiguration') { if (action.action === 'showConfiguration') {
@ -26,13 +27,13 @@ export default class MIGCONFIGURATION extends HarvesterResource {
out.push( out.push(
{ {
action: 'enableConfig', action: 'enableConfig',
enabled: !this.isEnabled, enabled: !this.isEnabled && canUpdate,
icon: 'icon icon-fw icon-dot', icon: 'icon icon-fw icon-dot',
label: 'Enable', label: 'Enable',
}, },
{ {
action: 'disableConfig', action: 'disableConfig',
enabled: this.isEnabled, enabled: this.isEnabled && canUpdate,
icon: 'icon icon-fw icon-dot-open', icon: 'icon icon-fw icon-dot-open',
label: 'Disable', label: 'Disable',
}, },

View File

@ -30,12 +30,11 @@ const STATUS_DISPLAY = {
export default class PCIDevice extends SteveModel { export default class PCIDevice extends SteveModel {
get _availableActions() { get _availableActions() {
const out = super._availableActions; const out = super._availableActions;
const canUpdate = !!this.linkFor('update');
out.push( out.push(
{ {
action: 'enablePassthroughBulk', action: 'enablePassthroughBulk',
enabled: !this.isEnabling && !this.isvGPUDevice && canUpdate, enabled: !this.isEnabling && !this.isvGPUDevice && this.canUpdate,
icon: 'icon icon-fw icon-dot', icon: 'icon icon-fw icon-dot',
label: 'Enable Passthrough', label: 'Enable Passthrough',
bulkable: true, bulkable: true,
@ -44,7 +43,7 @@ export default class PCIDevice extends SteveModel {
}, },
{ {
action: 'disablePassthrough', action: 'disablePassthrough',
enabled: this.isEnabling && this.claimedByMe && !this.isvGPUDevice && canUpdate, enabled: this.isEnabling && this.claimedByMe && !this.isvGPUDevice && this.canUpdate,
icon: 'icon icon-fw icon-dot-open', icon: 'icon icon-fw icon-dot-open',
label: 'Disable Passthrough', label: 'Disable Passthrough',
bulkable: true, bulkable: true,
@ -55,6 +54,10 @@ export default class PCIDevice extends SteveModel {
return out; return out;
} }
get canUpdate() {
return !!this.linkFor('update');
}
get isvGPUDevice() { get isvGPUDevice() {
if (!this.vGPUAsPCIDeviceFeatureEnabled) { if (!this.vGPUAsPCIDeviceFeatureEnabled) {
return false; return false;

View File

@ -16,13 +16,13 @@ export default class SRIOVDevice extends SteveModel {
out.push( out.push(
{ {
action: 'enableDevice', action: 'enableDevice',
enabled: !this.isEnabled, enabled: !this.isEnabled && this.canUpdate,
icon: 'icon icon-fw icon-dot', icon: 'icon icon-fw icon-dot',
label: 'Enable', label: 'Enable',
}, },
{ {
action: 'disableDevice', action: 'disableDevice',
enabled: this.isEnabled, enabled: this.isEnabled && this.canUpdate,
icon: 'icon icon-fw icon-dot-open', icon: 'icon icon-fw icon-dot-open',
label: 'Disable', label: 'Disable',
}, },
@ -31,6 +31,10 @@ export default class SRIOVDevice extends SteveModel {
return out; return out;
} }
get canUpdate() {
return !!this.linkFor('update');
}
get canYaml() { get canYaml() {
return false; return false;
} }

View File

@ -33,7 +33,7 @@ export default class USBDevice extends SteveModel {
out.push( out.push(
{ {
action: 'enablePassthroughBulk', action: 'enablePassthroughBulk',
enabled: !this.passthroughClaim && !this.status.enabled, enabled: !this.passthroughClaim && !this.status.enabled && this.canUpdate,
icon: 'icon icon-fw icon-dot', icon: 'icon icon-fw icon-dot',
label: 'Enable Passthrough', label: 'Enable Passthrough',
bulkable: true, bulkable: true,
@ -42,7 +42,7 @@ export default class USBDevice extends SteveModel {
}, },
{ {
action: 'disablePassthrough', action: 'disablePassthrough',
enabled: this.status.enabled, enabled: this.status.enabled && this.canUpdate,
icon: 'icon icon-fw icon-dot-open', icon: 'icon icon-fw icon-dot-open',
label: 'Disable Passthrough', label: 'Disable Passthrough',
bulkable: true, bulkable: true,
@ -53,6 +53,10 @@ export default class USBDevice extends SteveModel {
return out; return out;
} }
get canUpdate() {
return !!this.linkFor('update');
}
get canYaml() { get canYaml() {
return false; return false;
} }

View File

@ -27,17 +27,18 @@ const STATUS_DISPLAY = {
export default class VGpuDevice extends SteveModel { export default class VGpuDevice extends SteveModel {
get _availableActions() { get _availableActions() {
const out = super._availableActions; const out = super._availableActions;
const canUpdate = !!this.linkFor('update');
out.push( out.push(
{ {
action: 'enableVGpu', action: 'enableVGpu',
enabled: !this.isEnabled, enabled: !this.isEnabled && canUpdate,
icon: 'icon icon-fw icon-dot', icon: 'icon icon-fw icon-dot',
label: 'Enable', label: 'Enable',
}, },
{ {
action: 'disableVGpu', action: 'disableVGpu',
enabled: this.isEnabled, enabled: this.isEnabled && canUpdate,
icon: 'icon icon-fw icon-dot-open', icon: 'icon icon-fw icon-dot-open',
label: 'Disable', label: 'Disable',
bulkable: true, bulkable: true,

View File

@ -23,17 +23,17 @@ export default class HciVmTemplateVersion extends HarvesterResource {
}); });
const schema = this.$getters['schemaFor'](HCI.VM); const schema = this.$getters['schemaFor'](HCI.VM);
let canCreateVM = true; let canCreateVM = false;
if ( schema && !schema?.collectionMethods.find((x) => ['post'].includes(x.toLowerCase())) ) { if (schema?.collectionMethods.find((x) => ['post'].includes(x.toLowerCase())) ) {
canCreateVM = false; canCreateVM = true;
} }
return [ return [
{ {
action: 'launchFromTemplate', action: 'launchFromTemplate',
icon: 'icon icon-spinner', icon: 'icon icon-spinner',
disabled: !canCreateVM || !this.isReady, enabled: canCreateVM && this.isReady,
label: this.t('harvester.action.launchFormTemplate'), label: this.t('harvester.action.launchFormTemplate'),
}, },
{ {

View File

@ -31,22 +31,42 @@ export function registerAddonSideNav(store, productName, {
}, 600); }, 600);
}; };
const hasAccessibleSchema = (t) => {
try {
return !!store.getters[`${ productName }/schemaFor`]?.(t);
} catch (e) {
return false;
}
};
const showTypes = (visibleTypes) => {
store.commit('type-map/basicType', {
product: productName,
group: navGroup,
types: visibleTypes
});
};
const hideTypes = () => {
const basicTypes = store.state['type-map'].basicTypes[productName];
if (basicTypes) {
types.forEach((t) => delete basicTypes[t]);
}
};
// Adds or removes the resource IDs from the product visibility whitelist. // Adds or removes the resource IDs from the product visibility whitelist.
const setMenuVisibility = (visible) => { const setMenuVisibility = (visible) => {
if (visible) { const accessibleTypes = visible ? types.filter(hasAccessibleSchema) : [];
store.commit('type-map/basicType', {
product: productName,
group: navGroup,
types
});
} else {
// Manually delete the keys from the state object to hide them.
const basicTypes = store.state['type-map'].basicTypes[productName];
if (basicTypes) { // Always clear first to remove any previously-registered types that are
types.forEach((t) => delete basicTypes[t]); // no longer accessible (e.g. partial permission changes like types=[A,B] where B is dropped).
} hideTypes();
if (accessibleTypes.length > 0) {
showTypes(accessibleTypes);
} }
kickSideNav(); kickSideNav();
}; };