Add support for passing parameters while creating audit

This cr implements following features:
- Adds a new "Strategy Parameters" field accepts json fjson formatted
  parameters values.
- The Create Audit dialog also shows available parameters dynamically
  using AJAX for the selected strategy coming from Audit template.
- Impoved audit info view to include parameters

Closes-Bug: #2120704

Assisted-By: Cursor (claude-4-sonnet)
Change-Id: I0f5382e5f87f6ed533c733349573cbba4bf8903b
Signed-off-by: Chandan Kumar (raukadah) <chkumar@redhat.com>
This commit is contained in:
Chandan Kumar (raukadah)
2025-08-15 13:34:29 +00:00
committed by Chandan Kumar
parent 4a0f64b5cb
commit 6552f02ca6
8 changed files with 411 additions and 10 deletions

View File

@@ -0,0 +1,18 @@
---
features:
- |
Watcher Dashboard now supports passing strategy parameters when creating
audits, matching the OpenStack CLI ``openstack optimize audit create -p
<name=value>``.
A new "Strategy Parameters" field accepts all the parameters as a JSON object.
Server-side validation parses and validates the input, and the field remains
backward compatible when left empty.
The Create Audit dialog also shows available parameters for the selected
strategy (when the Watcher API exposes ``parameters_spec``), including
types, defaults, and descriptions from audit templates. This provides
real-time guidance to users and improves parity with the CLI experience.
The Audit Details view now displays the parameters that were used for the
audit.

View File

@@ -55,7 +55,8 @@ def insert_watcher_policy_file():
class Audit(base.APIDictWrapper):
_attrs = ('uuid', 'name', 'created_at', 'modified_at', 'deleted_at',
'state', 'audit_type', 'audit_template_uuid',
'audit_template_name', 'interval')
'audit_template_name', 'interval', 'parameters', 'auto_trigger',
'goal_name', 'strategy_name')
def __init__(self, apiresource, request=None):
super(Audit, self).__init__(apiresource)
@@ -63,7 +64,7 @@ class Audit(base.APIDictWrapper):
@classmethod
def create(cls, request, audit_template_uuid, audit_type, name=None,
auto_trigger=False, interval=None):
auto_trigger=False, interval=None, parameters=None):
"""Create an audit in Watcher
@@ -82,18 +83,28 @@ class Audit(base.APIDictWrapper):
:param name: Name for this audit
:type name: string
:param parameters: Strategy parameters (default: None)
:type parameters: dict
:return: the created Audit object
:rtype: :py:class:`~.Audit`
"""
# Build the parameters to pass to watcherclient
create_params = {
'audit_template_uuid': audit_template_uuid,
'audit_type': audit_type,
'auto_trigger': auto_trigger
}
if name:
create_params['name'] = name
if interval:
return watcherclient(request).audit.create(
audit_template_uuid=audit_template_uuid, audit_type=audit_type,
auto_trigger=auto_trigger, interval=interval, name=name)
else:
return watcherclient(request).audit.create(
audit_template_uuid=audit_template_uuid, audit_type=audit_type,
auto_trigger=auto_trigger, name=name)
create_params['interval'] = interval
if parameters:
create_params['parameters'] = parameters
return watcherclient(request).audit.create(**create_params)
@classmethod
def list(cls, request, **filters):
@@ -478,7 +489,7 @@ class Strategy(base.APIDictWrapper):
"""Strategy resource."""
_attrs = ('uuid', 'name', 'display_name', 'goal_uuid', 'goal_name',
'created_at', 'updated_at', 'deleted_at')
'created_at', 'updated_at', 'deleted_at', 'parameters_spec')
def __init__(self, apiresource, request=None):
super(Strategy, self).__init__(apiresource)

View File

@@ -16,6 +16,7 @@
"""
Forms for starting Watcher Audits.
"""
import json
import logging
from django.urls import reverse
@@ -57,6 +58,23 @@ class CreateForm(forms.SelfHandlingForm):
_("Interval (in seconds or cron"
" format)")}),
required=False)
parameters = forms.CharField(
label=_("Strategy Parameters (JSON)"),
help_text=_("Provide strategy parameters as a JSON object. "
"See examples on the right."),
widget=forms.widgets.Textarea(attrs={
'rows': 8,
'placeholder': ('{\n'
' "memory_threshold": 0.8,\n'
' "enable_migration": true,\n'
' "compute_nodes": [\n'
' {"src_node": "compute1", '
'"dst_node": "compute2"}\n'
' ]\n'
'}')
}),
required=False
)
failure_url = 'horizon:admin:audits:index'
auto_trigger = forms.BooleanField(label=_("Auto Trigger"),
required=False)
@@ -86,12 +104,42 @@ class CreateForm(forms.SelfHandlingForm):
choices.insert(0, ("", _("No Audit Template found")))
return choices
def _parse_parameters(self, param_string):
"""Parse parameters JSON string into a dictionary
:param param_string: String containing a JSON object
:returns: Dictionary of parsed parameters
"""
if not param_string or not param_string.strip():
return {}
try:
parsed = json.loads(param_string)
except ValueError as e:
raise forms.ValidationError(
_('Parameters must be valid JSON: %s') % str(e))
if not isinstance(parsed, dict):
raise forms.ValidationError(
_('Parameters must be a JSON object'))
return parsed
def clean(self):
cleaned_data = super(CreateForm, self).clean()
audit_type = cleaned_data.get('audit_type')
if audit_type == 'continuous' and not cleaned_data.get('interval'):
msg = _('Please input an interval for continuous audit')
raise forms.ValidationError(msg)
# Validate parameters
param_string = cleaned_data.get('parameters', '')
try:
parsed_params = self._parse_parameters(param_string)
cleaned_data['parsed_parameters'] = parsed_params
except forms.ValidationError:
raise # Re-raise the validation error
return cleaned_data
def handle(self, request, data):
@@ -104,6 +152,12 @@ class CreateForm(forms.SelfHandlingForm):
params['interval'] = data['interval']
else:
params['interval'] = None
# Add parsed parameters if they exist
parsed_parameters = data.get('parsed_parameters')
if parsed_parameters:
params['parameters'] = parsed_parameters
audit = watcher.Audit.create(request, **params)
message = _('Audit was successfully created.')
messages.success(request, message)

View File

@@ -25,4 +25,6 @@ urlpatterns = [
views.CreateView.as_view(), name='create'),
re_path(r'^(?P<audit_uuid>[^/]+)/detail$',
views.DetailView.as_view(), name='detail'),
re_path(r'^get_strategy_parameters/$',
views.get_strategy_parameters, name='get_strategy_parameters'),
]

View File

@@ -13,10 +13,13 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import json
import logging
from django.http import JsonResponse
from django.urls import reverse
from django.urls import reverse_lazy
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
import horizon.exceptions
from horizon import forms
@@ -24,6 +27,7 @@ import horizon.tables
import horizon.tabs
from horizon.utils import memoized
import horizon.workflows
import yaml
from watcher_dashboard.api import watcher
from watcher_dashboard.content.action_plans import tables as action_plan_tables
@@ -109,6 +113,46 @@ class DetailView(horizon.tables.MultiTableView):
redirect=self.redirect_url)
return audit
def _render_pretty_parameters(self, params):
"""Return a human-friendly rendering of parameters.
It is used to render parameters on the audit details page
in a YAML format for readability.
Rules:
- If params is a JSON string, parse it first.
- If result is a dict/list, pretty-print as YAML in a <pre> block.
- If result is a scalar (str/int/bool), return it as-is.
- On any error, fall back to the original params value.
"""
try:
obj = params
# Parameters may be stored as a JSON-serialized string. Try to
# decode it but tolerate non-JSON strings (e.g. plain text).
if isinstance(params, str):
try:
obj = json.loads(params)
except Exception:
obj = params
# Dicts/lists are presented as indented YAML for readability.
if isinstance(obj, (dict, list)):
dumped = yaml.safe_dump(
obj,
default_flow_style=False,
sort_keys=False,
)
return mark_safe(
'<pre style="margin:0">{}</pre>'.format(dumped)
)
# Scalars or unknown types: return directly.
if obj is not None:
return obj
except Exception:
# Any unexpected issue: show the raw parameters.
return params
def get_related_action_plans_data(self):
try:
action_plan = self._get_data()
@@ -125,9 +169,62 @@ class DetailView(horizon.tables.MultiTableView):
context = super(DetailView, self).get_context_data(**kwargs)
audit = self._get_data()
context["audit"] = audit
# Prepare pretty parameters rendering (YAML) for the template. The
# helper encapsulates the logic so it is easier to test/maintain.
context["audit_parameters_pretty"] = self._render_pretty_parameters(
getattr(audit, 'parameters', None)
)
return context
def get_tabs(self, request, *args, **kwargs):
audit = self._get_data()
# ports = self._get_ports()
return self.tab_group_class(request, audit=audit, **kwargs)
def get_strategy_parameters(request):
"""AJAX endpoint to get strategy parameters based on audit template."""
try:
audit_template_uuid = request.GET.get('audit_template_uuid')
if not audit_template_uuid:
return JsonResponse(
{'error': 'Audit template UUID is required'},
status=400)
# Get the audit template
audit_template = watcher.AuditTemplate.get(
request, audit_template_uuid)
if not audit_template.strategy_uuid:
return JsonResponse({
'strategy_name': audit_template.strategy_name or 'auto',
'parameters_spec': {},
'message': ('No specific strategy selected. Parameters will '
'be automatically determined.')
})
# Get the strategy details
strategy = watcher.Strategy.get(request, audit_template.strategy_uuid)
# Parse parameters_spec if it exists
parameters_spec = {}
if hasattr(strategy, 'parameters_spec') and strategy.parameters_spec:
if isinstance(strategy.parameters_spec, dict):
properties = strategy.parameters_spec.get('properties', {})
parameters_spec = properties
elif isinstance(strategy.parameters_spec, str):
try:
parsed_spec = json.loads(strategy.parameters_spec)
parameters_spec = parsed_spec.get('properties', {})
except ValueError:
parameters_spec = {}
return JsonResponse({
'strategy_name': strategy.display_name or strategy.name,
'strategy_uuid': strategy.uuid,
'parameters_spec': parameters_spec
})
except Exception as e:
LOG.exception("Error getting strategy parameters")
return JsonResponse({'error': str(e)}, status=500)

View File

@@ -5,4 +5,143 @@
<h3>{% trans "Description:" %}</h3>
<p>{% trans "Creates a audit with specified parameters." %}</p>
<p>{% trans "If you check 'Auto Trigger' option, the action plan, recommended by the audit, will be automatically started." %}</p>
<h3>{% trans "Strategy Parameters (JSON):" %}</h3>
<p>{% trans "Strategy parameters control how the optimization strategy behaves during audit execution." %}</p>
<div id="strategy-info" style="display: none; margin: 10px 0; padding: 10px; background: #e8f4fd; border-left: 4px solid #0088cc; border-radius: 4px;">
<h4 id="strategy-name" style="margin: 0 0 10px 0; color: #0088cc;"></h4>
<div id="parameters-list"></div>
</div>
<div id="loading-strategy" style="display: none; margin: 10px 0; padding: 10px; background: #f5f5f5; border-radius: 4px;">
<p style="margin: 0;"><i class="fa fa-spinner fa-spin"></i> {% trans "Loading strategy parameters..." %}</p>
</div>
<p><strong>{% trans "Parameter format:" %}</strong><br/>
{% trans "Enter a JSON object with parameter names as keys." %}
</p>
<p><strong>{% trans "Example:" %}</strong></p>
<pre style="font-size: 12px; padding: 8px; background: #f8f9fa; border: 1px solid #eee; border-radius: 4px;">
{
"memory_threshold": 0.8,
"cpu_threshold": 0.9,
"enable_migration": true,
"compute_nodes": [
{"src_node": "compute1", "dst_node": "compute2"}
]
}
</pre>
<script>
$(document).ready(function() {
var auditTemplateSelect = $('#id_audit_template');
var strategyInfo = $('#strategy-info');
var loadingDiv = $('#loading-strategy');
var strategyName = $('#strategy-name');
var parametersList = $('#parameters-list');
function loadStrategyParameters(auditTemplateUuid) {
if (!auditTemplateUuid) {
strategyInfo.hide();
return;
}
loadingDiv.show();
strategyInfo.hide();
$.ajax({
url: "{% url 'horizon:admin:audits:get_strategy_parameters' %}",
data: { audit_template_uuid: auditTemplateUuid },
success: function(data) {
loadingDiv.hide();
if (data.error) {
console.error('Error loading strategy parameters:', data.error);
return;
}
strategyName.text('Strategy: ' + data.strategy_name);
var paramHtml = '';
if (data.parameters_spec && Object.keys(data.parameters_spec).length > 0) {
paramHtml = '<p><strong>Available parameters:</strong></p><ul style="margin: 5px 0; padding-left: 20px;">';
for (var param in data.parameters_spec) {
var spec = data.parameters_spec[param];
var typeInfo = spec.type ? ' (type: ' + spec.type + ')' : '';
var defaultInfo = '';
if (spec.hasOwnProperty('default')) {
var renderedDefault = spec.default;
// Render objects/arrays as compact JSON
if (renderedDefault && (typeof renderedDefault === 'object')) {
try {
renderedDefault = JSON.stringify(renderedDefault);
} catch (e) {
renderedDefault = String(renderedDefault);
}
}
defaultInfo = ' - default: ' + renderedDefault;
}
var description = spec.description ? ' - ' + spec.description : '';
paramHtml += '<li style="margin: 2px 0;"><code>' + param + '</code>' + typeInfo + defaultInfo + description + '</li>';
}
paramHtml += '</ul>';
} else if (data.message) {
paramHtml = '<p><em>' + data.message + '</em></p>';
} else {
paramHtml = '<p><em>No parameter specifications available for this strategy.</em></p>';
}
parametersList.html(paramHtml);
strategyInfo.show();
},
error: function(xhr, status, error) {
loadingDiv.hide();
console.error('AJAX error:', error);
}
});
}
// Load parameters when audit template changes
auditTemplateSelect.change(function() {
loadStrategyParameters($(this).val());
});
// Some Horizon helpers may programmatically update the select without
// triggering a native change event (e.g., after creating a new
// Audit Template via the + button). Use a MutationObserver to detect
// option/selection changes and load parameters accordingly.
var lastSelectedValue = auditTemplateSelect.val();
if (window.MutationObserver) {
var observer = new MutationObserver(function(mutations) {
var current = auditTemplateSelect.val();
if (current !== lastSelectedValue) {
lastSelectedValue = current;
loadStrategyParameters(current);
}
});
observer.observe(auditTemplateSelect[0], {
attributes: true,
childList: true,
subtree: true
});
}
// If Chosen or similar libraries are used, listen for their update event
// to catch programmatic selection changes.
auditTemplateSelect.on('chosen:updated', function() {
var current = auditTemplateSelect.val();
if (current !== lastSelectedValue) {
lastSelectedValue = current;
loadStrategyParameters(current);
}
});
// Load parameters for initially selected template
if (auditTemplateSelect.val()) {
loadStrategyParameters(auditTemplateSelect.val());
}
});
</script>
{% endblock %}

View File

@@ -19,6 +19,10 @@
<dd>{{ audit.strategy_name|default:_("-") }}</dd>
<dt>{% trans "Type" %}</dt>
<dd>{{ audit.audit_type|default:_("-") }}</dd>
{% if audit_parameters_pretty %}
<dt>{% trans "Parameters" %}</dt>
<dd>{{ audit_parameters_pretty|safe }}</dd>
{% endif %}
<dt>{% trans "Auto Trigger" %}</dt>
<dd>{{ audit.auto_trigger }}</dd>
{% url 'horizon:admin:audit_templates:detail' audit.audit_template_uuid as audit_template_url %}

View File

@@ -273,6 +273,82 @@ class WatcherAPITests(test.APITestCase):
auto_trigger=True,
name=audit_name)
def test_audit_create_with_parameters(self):
audit = self.api_audits.first()
audit_template_id = self.api_audit_templates.first()['uuid']
audit_type = self.api_audits.first()['audit_type']
audit_name = self.api_audits.first()['name']
audit_template_uuid = audit_template_id
parameters = {'memory_threshold': 0.8, 'cpu_threshold': 0.9}
watcherclient = self.stub_watcherclient()
watcherclient.audit.create = mock.Mock(
return_value=audit)
ret_val = api.watcher.Audit.create(
self.request, audit_template_uuid, audit_type, audit_name,
False, None, parameters)
self.assertIsInstance(ret_val, dict)
watcherclient.audit.create.assert_called_with(
audit_template_uuid=audit_template_uuid,
audit_type=audit_type,
auto_trigger=False,
name=audit_name,
parameters=parameters)
def test_audit_create_with_complex_parameters(self):
audit = self.api_audits.first()
audit_template_id = self.api_audit_templates.first()['uuid']
audit_type = self.api_audits.first()['audit_type']
audit_name = self.api_audits.first()['name']
audit_template_uuid = audit_template_id
# Test complex JSON parameters like arrays and objects
parameters = {
'memory_threshold': 0.8,
'enable_migration': True,
'compute_nodes': [{'src_node': 's01', 'dst_node': 'd01'}],
'excluded_instances': ['instance1', 'instance2']
}
watcherclient = self.stub_watcherclient()
watcherclient.audit.create = mock.Mock(
return_value=audit)
ret_val = api.watcher.Audit.create(
self.request, audit_template_uuid, audit_type, audit_name,
False, None, parameters)
self.assertIsInstance(ret_val, dict)
watcherclient.audit.create.assert_called_with(
audit_template_uuid=audit_template_uuid,
audit_type=audit_type,
auto_trigger=False,
name=audit_name,
parameters=parameters)
def test_audit_show_with_parameters(self):
"""Test that audit detail view shows parameters when present"""
audit = self.api_audits.first()
audit_id = audit['uuid']
# Add parameters to the audit data
audit_with_params = dict(audit)
audit_with_params['parameters'] = {
'memory_threshold': 0.8,
'cpu_threshold': 0.9
}
watcherclient = self.stub_watcherclient()
watcherclient.audit.get = mock.Mock(
return_value=audit_with_params)
ret_val = api.watcher.Audit.get(self.request, audit_id)
self.assertIsInstance(ret_val, dict)
expected_params = {'memory_threshold': 0.8, 'cpu_threshold': 0.9}
self.assertEqual(ret_val['parameters'], expected_params)
watcherclient.audit.get.assert_called_with(
audit=audit_id)
def test_audit_delete(self):
audit_id = self.api_audits.first()['uuid']