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:
2025-10-02 19:16:22 -03:00
parent 74f579050c
commit 0a0d3e1f43

View File

@@ -830,6 +830,178 @@
background-color: var(--pf-global--success-color--100); background-color: var(--pf-global--success-color--100);
color: white; 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> </style>
</head> </head>
<body> <body>
@@ -1049,13 +1221,68 @@
<!-- Smart Recommendations Content --> <!-- Smart Recommendations Content -->
<div class="openshift-card"> <div class="openshift-card">
<div class="card-header"> <div class="card-header">
<h2 class="card-title">Recommendations</h2> <h2 class="card-title">VPA Recommendations</h2>
<div class="card-actions">
<button class="openshift-button" onclick="loadSmartRecommendations()"> <button class="openshift-button" onclick="loadSmartRecommendations()">
<i class="fas fa-sync-alt"></i> <i class="fas fa-sync-alt"></i>
Refresh Refresh
</button> </button>
</div> </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"> <div class="loading-spinner">
<i class="fas fa-spinner fa-spin"></i> <i class="fas fa-spinner fa-spin"></i>
Loading recommendations... Loading recommendations...
@@ -1254,6 +1481,7 @@
// Store recommendations globally for button functions // Store recommendations globally for button functions
window.currentRecommendations = data.recommendations || []; window.currentRecommendations = data.recommendations || [];
window.selectedRecommendations = new Set();
if (!data || !data.recommendations || data.recommendations.length === 0) { if (!data || !data.recommendations || data.recommendations.length === 0) {
container.innerHTML = ` container.innerHTML = `
@@ -1263,75 +1491,243 @@
<p>No smart recommendations found for the current cluster state.</p> <p>No smart recommendations found for the current cluster state.</p>
</div> </div>
`; `;
updateBulkSelectUI();
return; return;
} }
const recommendationsHtml = data.recommendations.map(rec => ` // Update bulk select counters
<div class="openshift-card" style="margin-bottom: 16px;"> document.getElementById('total-recommendations').textContent = data.recommendations.length;
<div class="card-header"> document.getElementById('page-recommendations').textContent = data.recommendations.length;
<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 ? ` const recommendationsHtml = data.recommendations.map((rec, index) => `
<div style="margin-bottom: 16px;"> <div class="service-card" id="recommendation-${index}" data-recommendation-id="${index}">
<strong style="color: var(--pf-global--Color--100);">Affected Workloads:</strong> <div class="service-card-header">
<ul style="margin: 8px 0 0 20px; color: var(--pf-global--Color--200);"> <div class="service-card-icon">
${rec.workload_list.map(workload => { <i class="fas fa-${getRecommendationIcon(rec.recommendation_type)}"></i>
// Extract priority from workload string (e.g., "example (shishika01) - HIGH") </div>
const priorityMatch = workload.match(/\s-\s(\w+)$/); <h3 class="service-card-title">${rec.title}</h3>
const priority = priorityMatch ? priorityMatch[1].toLowerCase() : 'low'; <div class="service-card-checkbox">
const priorityColor = getPriorityColor(priority); <input type="checkbox"
return `<li style="color: ${priorityColor}; font-weight: 500;">${workload}</li>`; id="checkbox-${index}"
}).join('')} class="recommendation-checkbox"
</ul> onchange="toggleRecommendationSelection(${index})"
style="transform: scale(1.2);">
</div> </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>
` : ''}
<div style="display: flex; gap: 12px; margin-top: 20px; flex-wrap: wrap;"> <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 ? ` ${rec.vpa_yaml ? `
<button class="openshift-button openshift-button-primary" onclick="downloadVPAYAML('${rec.workload_name}', '${rec.namespace}')"> <button class="openshift-button openshift-button-primary" onclick="downloadVPAYAML('${rec.workload_name}', '${rec.namespace}')">
<i class="fas fa-download"></i> <i class="fas fa-download"></i>
Generate VPA YAML 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>
` : ''} ` : ''}
<button class="openshift-button openshift-button-success" onclick="applySmartRecommendation('${rec.workload_name}', '${rec.namespace}', '${rec.recommendation_type}', '${rec.priority}')"> <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> <i class="fas fa-check"></i>
Apply Recommendation Apply
</button> </button>
<button class="openshift-button" onclick="previewSmartRecommendation('${rec.workload_name}', '${rec.namespace}', '${rec.recommendation_type}', '${rec.priority}')"> <button class="openshift-button" onclick="previewSmartRecommendation('${rec.workload_name}', '${rec.namespace}', '${rec.recommendation_type}', '${rec.priority}')">
<i class="fas fa-eye"></i> <i class="fas fa-eye"></i>
Preview Changes Preview
</button> </button>
</div> </div>
</div> </div>
</div>
`).join(''); `).join('');
container.innerHTML = recommendationsHtml; 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 // Load VPA management