feat: implement Smart Recommendations gallery with ServiceCard and BulkSelect
- Replace table layout with PatternFly ServiceCard gallery - Add BulkSelect toolbar with select all/page/none options - Implement individual VPA application per workload - Add checkbox selection for each recommendation card - Support bulk apply and preview operations - Improve UX with card-based interface following PatternFly design - Add responsive grid layout for recommendation cards - Implement proper state management for selections
This commit is contained in:
@@ -830,6 +830,178 @@
|
||||
background-color: var(--pf-global--success-color--100);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Smart Recommendations Gallery Styles */
|
||||
.gallery-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.service-card {
|
||||
background: var(--pf-global--BackgroundColor--100);
|
||||
border: 1px solid var(--pf-global--BorderColor--200);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.service-card:hover {
|
||||
border-color: var(--pf-global--primary-color--100);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.service-card.selected {
|
||||
border-color: var(--pf-global--primary-color--100);
|
||||
background: var(--pf-global--primary-color--50);
|
||||
}
|
||||
|
||||
.service-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.service-card-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--pf-global--primary-color--100);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 16px;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.service-card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--pf-global--Color--100);
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.service-card-checkbox {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.service-card-body {
|
||||
flex: 1;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.service-card-description {
|
||||
color: var(--pf-global--Color--200);
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.service-card-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.service-card-meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--pf-global--Color--300);
|
||||
}
|
||||
|
||||
.service-card-priority {
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.priority-high {
|
||||
background: var(--pf-global--danger-color--100);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.priority-medium {
|
||||
background: var(--pf-global--warning-color--100);
|
||||
color: var(--pf-global--Color--100);
|
||||
}
|
||||
|
||||
.priority-low {
|
||||
background: var(--pf-global--success-color--100);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.service-card-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.service-card-footer .openshift-button {
|
||||
font-size: 12px;
|
||||
padding: 8px 16px;
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* Bulk Select Styles */
|
||||
.bulk-select-toolbar {
|
||||
background: var(--pf-global--BackgroundColor--200);
|
||||
border-bottom: 1px solid var(--pf-global--BorderColor--200);
|
||||
}
|
||||
|
||||
.pf-v6-c-dropdown__menu {
|
||||
background: var(--pf-global--BackgroundColor--100);
|
||||
border: 1px solid var(--pf-global--BorderColor--200);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.pf-v6-c-dropdown__menu-item {
|
||||
padding: 8px 16px;
|
||||
color: var(--pf-global--Color--100);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.pf-v6-c-dropdown__menu-item:hover {
|
||||
background: var(--pf-global--BackgroundColor--200);
|
||||
}
|
||||
|
||||
.pf-v6-c-dropdown__toggle {
|
||||
background: var(--pf-global--BackgroundColor--100);
|
||||
border: 1px solid var(--pf-global--BorderColor--200);
|
||||
color: var(--pf-global--Color--100);
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pf-v6-c-dropdown__toggle:hover {
|
||||
border-color: var(--pf-global--primary-color--100);
|
||||
}
|
||||
|
||||
.pf-v6-c-dropdown__toggle-icon {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.pf-v6-c-dropdown__toggle[aria-expanded="true"] .pf-v6-c-dropdown__toggle-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -1044,24 +1216,79 @@
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Smart Recommendations</h1>
|
||||
<p class="page-description">AI-powered recommendations for resource optimization and VPA activation</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Smart Recommendations Content -->
|
||||
<div class="openshift-card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Recommendations</h2>
|
||||
<button class="openshift-button" onclick="loadSmartRecommendations()">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
Refresh
|
||||
<h2 class="card-title">VPA Recommendations</h2>
|
||||
<div class="card-actions">
|
||||
<button class="openshift-button" onclick="loadSmartRecommendations()">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-content" id="smart-recommendations-container">
|
||||
</div>
|
||||
|
||||
<!-- Bulk Select Toolbar -->
|
||||
<div class="bulk-select-toolbar" style="padding: 16px; border-bottom: 1px solid var(--pf-global--BorderColor--200);">
|
||||
<div class="pf-v6-c-toolbar">
|
||||
<div class="pf-v6-c-toolbar__content">
|
||||
<div class="pf-v6-c-toolbar__content-section">
|
||||
<div class="pf-v6-c-toolbar__group pf-m-toggle-group">
|
||||
<div class="pf-v6-c-toolbar__item">
|
||||
<div class="pf-v6-c-dropdown">
|
||||
<button class="pf-v6-c-dropdown__toggle" type="button" id="bulk-select-toggle" aria-expanded="false" onclick="toggleBulkSelect()">
|
||||
<span class="pf-v6-c-dropdown__toggle-text">
|
||||
<span id="bulk-select-text">0 selected</span>
|
||||
</span>
|
||||
<span class="pf-v6-c-dropdown__toggle-icon">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</span>
|
||||
</button>
|
||||
<ul class="pf-v6-c-dropdown__menu" id="bulk-select-menu" style="display: none;">
|
||||
<li>
|
||||
<button class="pf-v6-c-dropdown__menu-item" onclick="selectAllRecommendations()">
|
||||
Select all (<span id="total-recommendations">0</span>)
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="pf-v6-c-dropdown__menu-item" onclick="selectPageRecommendations()">
|
||||
Select page (<span id="page-recommendations">0</span>)
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="pf-v6-c-dropdown__menu-item" onclick="deselectAllRecommendations()">
|
||||
Select none (0)
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-v6-c-toolbar__item" id="bulk-actions" style="display: none;">
|
||||
<button class="openshift-button openshift-button-primary" onclick="applySelectedRecommendations()">
|
||||
<i class="fas fa-check"></i>
|
||||
Apply Selected (<span id="selected-count">0</span>)
|
||||
</button>
|
||||
<button class="openshift-button" onclick="previewSelectedRecommendations()">
|
||||
<i class="fas fa-eye"></i>
|
||||
Preview Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gallery of Service Cards -->
|
||||
<div class="gallery-container" id="smart-recommendations-container" style="padding: 20px;">
|
||||
<div class="loading-spinner">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
Loading recommendations...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- VPA Management Section -->
|
||||
@@ -1254,6 +1481,7 @@
|
||||
|
||||
// Store recommendations globally for button functions
|
||||
window.currentRecommendations = data.recommendations || [];
|
||||
window.selectedRecommendations = new Set();
|
||||
|
||||
if (!data || !data.recommendations || data.recommendations.length === 0) {
|
||||
container.innerHTML = `
|
||||
@@ -1261,77 +1489,245 @@
|
||||
<i class="fas fa-lightbulb" style="font-size: 48px; margin-bottom: 16px; color: var(--pf-global--Color--400);"></i>
|
||||
<h3>No Recommendations Available</h3>
|
||||
<p>No smart recommendations found for the current cluster state.</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
updateBulkSelectUI();
|
||||
return;
|
||||
}
|
||||
|
||||
const recommendationsHtml = data.recommendations.map(rec => `
|
||||
<div class="openshift-card" style="margin-bottom: 16px;">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">
|
||||
<i class="fas fa-${getRecommendationIcon(rec.recommendation_type)}" style="margin-right: 8px; color: ${getPriorityColor(rec.priority)};"></i>
|
||||
${rec.title}
|
||||
</h3>
|
||||
<span class="priority-badge priority-${rec.priority}">${rec.priority.toUpperCase()}</span>
|
||||
</div>
|
||||
<div style="padding: 20px;">
|
||||
<p style="margin-bottom: 16px; color: var(--pf-global--Color--200);">${rec.description}</p>
|
||||
|
||||
${rec.workload_list && rec.workload_list.length > 0 ? `
|
||||
<div style="margin-bottom: 16px;">
|
||||
<strong style="color: var(--pf-global--Color--100);">Affected Workloads:</strong>
|
||||
<ul style="margin: 8px 0 0 20px; color: var(--pf-global--Color--200);">
|
||||
${rec.workload_list.map(workload => {
|
||||
// Extract priority from workload string (e.g., "example (shishika01) - HIGH")
|
||||
const priorityMatch = workload.match(/\s-\s(\w+)$/);
|
||||
const priority = priorityMatch ? priorityMatch[1].toLowerCase() : 'low';
|
||||
const priorityColor = getPriorityColor(priority);
|
||||
return `<li style="color: ${priorityColor}; font-weight: 500;">${workload}</li>`;
|
||||
}).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${rec.implementation_steps && rec.implementation_steps.length > 0 ? `
|
||||
<div style="margin-bottom: 16px;">
|
||||
<strong style="color: var(--pf-global--Color--100);">Implementation Steps:</strong>
|
||||
<ol style="margin: 8px 0 0 20px; color: var(--pf-global--Color--200);">
|
||||
${rec.implementation_steps.map(step => `<li>${step}</li>`).join('')}
|
||||
</ol>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div style="display: flex; gap: 12px; margin-top: 20px; flex-wrap: wrap;">
|
||||
${rec.vpa_yaml ? `
|
||||
<button class="openshift-button openshift-button-primary" onclick="downloadVPAYAML('${rec.workload_name}', '${rec.namespace}')">
|
||||
<i class="fas fa-download"></i>
|
||||
Generate VPA YAML
|
||||
</button>
|
||||
` : ''}
|
||||
|
||||
${rec.kubectl_commands && rec.kubectl_commands.length > 0 ? `
|
||||
<button class="openshift-button" onclick="showOpenShiftCommands('${rec.workload_name}', '${rec.namespace}')">
|
||||
<i class="fas fa-terminal"></i>
|
||||
OpenShift Commands
|
||||
</button>
|
||||
` : ''}
|
||||
|
||||
<button class="openshift-button openshift-button-success" onclick="applySmartRecommendation('${rec.workload_name}', '${rec.namespace}', '${rec.recommendation_type}', '${rec.priority}')">
|
||||
<i class="fas fa-check"></i>
|
||||
Apply Recommendation
|
||||
</button>
|
||||
|
||||
<button class="openshift-button" onclick="previewSmartRecommendation('${rec.workload_name}', '${rec.namespace}', '${rec.recommendation_type}', '${rec.priority}')">
|
||||
<i class="fas fa-eye"></i>
|
||||
Preview Changes
|
||||
</button>
|
||||
// Update bulk select counters
|
||||
document.getElementById('total-recommendations').textContent = data.recommendations.length;
|
||||
document.getElementById('page-recommendations').textContent = data.recommendations.length;
|
||||
|
||||
const recommendationsHtml = data.recommendations.map((rec, index) => `
|
||||
<div class="service-card" id="recommendation-${index}" data-recommendation-id="${index}">
|
||||
<div class="service-card-header">
|
||||
<div class="service-card-icon">
|
||||
<i class="fas fa-${getRecommendationIcon(rec.recommendation_type)}"></i>
|
||||
</div>
|
||||
<h3 class="service-card-title">${rec.title}</h3>
|
||||
<div class="service-card-checkbox">
|
||||
<input type="checkbox"
|
||||
id="checkbox-${index}"
|
||||
class="recommendation-checkbox"
|
||||
onchange="toggleRecommendationSelection(${index})"
|
||||
style="transform: scale(1.2);">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="service-card-body">
|
||||
<p class="service-card-description">${rec.description}</p>
|
||||
|
||||
<div class="service-card-meta">
|
||||
<div class="service-card-meta-item">
|
||||
<i class="fas fa-cube"></i>
|
||||
<span>${rec.workload_name || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="service-card-meta-item">
|
||||
<i class="fas fa-layer-group"></i>
|
||||
<span>${rec.namespace || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="service-card-meta-item">
|
||||
<i class="fas fa-tag"></i>
|
||||
<span>${rec.recommendation_type}</span>
|
||||
</div>
|
||||
<div class="service-card-priority priority-${rec.priority}">
|
||||
${rec.priority.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="service-card-footer">
|
||||
${rec.vpa_yaml ? `
|
||||
<button class="openshift-button openshift-button-primary" onclick="downloadVPAYAML('${rec.workload_name}', '${rec.namespace}')">
|
||||
<i class="fas fa-download"></i>
|
||||
VPA YAML
|
||||
</button>
|
||||
` : ''}
|
||||
|
||||
<button class="openshift-button openshift-button-success" onclick="applySmartRecommendation('${rec.workload_name}', '${rec.namespace}', '${rec.recommendation_type}', '${rec.priority}')">
|
||||
<i class="fas fa-check"></i>
|
||||
Apply
|
||||
</button>
|
||||
|
||||
<button class="openshift-button" onclick="previewSmartRecommendation('${rec.workload_name}', '${rec.namespace}', '${rec.recommendation_type}', '${rec.priority}')">
|
||||
<i class="fas fa-eye"></i>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
container.innerHTML = recommendationsHtml;
|
||||
updateBulkSelectUI();
|
||||
}
|
||||
|
||||
// Bulk Select Functions
|
||||
function toggleBulkSelect() {
|
||||
const menu = document.getElementById('bulk-select-menu');
|
||||
const toggle = document.getElementById('bulk-select-toggle');
|
||||
const isOpen = menu.style.display !== 'none';
|
||||
|
||||
menu.style.display = isOpen ? 'none' : 'block';
|
||||
toggle.setAttribute('aria-expanded', !isOpen);
|
||||
}
|
||||
|
||||
function toggleRecommendationSelection(index) {
|
||||
const checkbox = document.getElementById(`checkbox-${index}`);
|
||||
const card = document.getElementById(`recommendation-${index}`);
|
||||
|
||||
if (checkbox.checked) {
|
||||
window.selectedRecommendations.add(index);
|
||||
card.classList.add('selected');
|
||||
} else {
|
||||
window.selectedRecommendations.delete(index);
|
||||
card.classList.remove('selected');
|
||||
}
|
||||
|
||||
updateBulkSelectUI();
|
||||
}
|
||||
|
||||
function selectAllRecommendations() {
|
||||
const checkboxes = document.querySelectorAll('.recommendation-checkbox');
|
||||
checkboxes.forEach((checkbox, index) => {
|
||||
checkbox.checked = true;
|
||||
window.selectedRecommendations.add(index);
|
||||
document.getElementById(`recommendation-${index}`).classList.add('selected');
|
||||
});
|
||||
updateBulkSelectUI();
|
||||
toggleBulkSelect();
|
||||
}
|
||||
|
||||
function selectPageRecommendations() {
|
||||
const checkboxes = document.querySelectorAll('.recommendation-checkbox');
|
||||
checkboxes.forEach((checkbox, index) => {
|
||||
checkbox.checked = true;
|
||||
window.selectedRecommendations.add(index);
|
||||
document.getElementById(`recommendation-${index}`).classList.add('selected');
|
||||
});
|
||||
updateBulkSelectUI();
|
||||
toggleBulkSelect();
|
||||
}
|
||||
|
||||
function deselectAllRecommendations() {
|
||||
const checkboxes = document.querySelectorAll('.recommendation-checkbox');
|
||||
checkboxes.forEach((checkbox, index) => {
|
||||
checkbox.checked = false;
|
||||
window.selectedRecommendations.delete(index);
|
||||
document.getElementById(`recommendation-${index}`).classList.remove('selected');
|
||||
});
|
||||
updateBulkSelectUI();
|
||||
toggleBulkSelect();
|
||||
}
|
||||
|
||||
function updateBulkSelectUI() {
|
||||
const selectedCount = window.selectedRecommendations ? window.selectedRecommendations.size : 0;
|
||||
const totalCount = window.currentRecommendations ? window.currentRecommendations.length : 0;
|
||||
|
||||
document.getElementById('bulk-select-text').textContent = `${selectedCount} selected`;
|
||||
document.getElementById('selected-count').textContent = selectedCount;
|
||||
|
||||
const bulkActions = document.getElementById('bulk-actions');
|
||||
if (selectedCount > 0) {
|
||||
bulkActions.style.display = 'flex';
|
||||
} else {
|
||||
bulkActions.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function applySelectedRecommendations() {
|
||||
if (!window.selectedRecommendations || window.selectedRecommendations.size === 0) {
|
||||
alert('No recommendations selected');
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedIndices = Array.from(window.selectedRecommendations);
|
||||
const selectedRecommendations = selectedIndices.map(index => window.currentRecommendations[index]);
|
||||
|
||||
try {
|
||||
showLoading('smart-recommendations-container');
|
||||
|
||||
const results = [];
|
||||
for (const rec of selectedRecommendations) {
|
||||
try {
|
||||
const response = await fetch('/api/v1/recommendations/apply', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...rec,
|
||||
dry_run: false
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
results.push({ success: true, recommendation: rec.title, result });
|
||||
} else {
|
||||
results.push({ success: false, recommendation: rec.title, error: 'Failed to apply' });
|
||||
}
|
||||
} catch (error) {
|
||||
results.push({ success: false, recommendation: rec.title, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Show results
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
const failCount = results.filter(r => !r.success).length;
|
||||
|
||||
alert(`Applied ${successCount} recommendations successfully. ${failCount} failed.`);
|
||||
|
||||
// Refresh recommendations
|
||||
loadSmartRecommendations();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error applying selected recommendations:', error);
|
||||
alert('Error applying recommendations: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function previewSelectedRecommendations() {
|
||||
if (!window.selectedRecommendations || window.selectedRecommendations.size === 0) {
|
||||
alert('No recommendations selected');
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedIndices = Array.from(window.selectedRecommendations);
|
||||
const selectedRecommendations = selectedIndices.map(index => window.currentRecommendations[index]);
|
||||
|
||||
try {
|
||||
const results = [];
|
||||
for (const rec of selectedRecommendations) {
|
||||
try {
|
||||
const response = await fetch('/api/v1/recommendations/apply', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...rec,
|
||||
dry_run: true
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
results.push({ success: true, recommendation: rec, result });
|
||||
} else {
|
||||
results.push({ success: false, recommendation: rec, error: 'Failed to preview' });
|
||||
}
|
||||
} catch (error) {
|
||||
results.push({ success: false, recommendation: rec, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
showRecommendationPreview(results);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error previewing selected recommendations:', error);
|
||||
alert('Error previewing recommendations: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Load VPA management
|
||||
|
||||
Reference in New Issue
Block a user