diff --git a/CHANGELOG.md b/CHANGELOG.md index 72fb870..c0812a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.3.1 / 2024-06-01 + +* [ENHANCEMENT] Added a new webpage, Metrics Management, based on the `/metrics-lifecycle-policies` API. This feature allows +for directly defining and managing policies for retaining Prometheus metrics. #23 +* [ENHANCEMENT] Added support for dark mode on the Rules Management page. #16 +* [ENHANCEMENT] Added support of filtering of rules by their type from the UI. #15 + ## 0.3.0 / 2024-05-26 * [ENHANCEMENT] diff --git a/src/api/v1/endpoints/web.py b/src/api/v1/endpoints/web.py index 71b06ef..1b2d844 100644 --- a/src/api/v1/endpoints/web.py +++ b/src/api/v1/endpoints/web.py @@ -9,6 +9,7 @@ if arg_parser().get("web.enable_ui") == "true": rules_management = "ui/rules-management" + metrics_management = "ui/metrics-management" logger.info("Starting web management UI") @router.get("/", response_class=HTMLResponse, @@ -38,3 +39,25 @@ async def rules_management_files(path, request: Request): "method": request.method, "request_path": request.url.path}) return f"{sts} {msg}" + + @router.get("/metrics-management", + description="RRenders metrics management HTML page of this application", + include_in_schema=False) + async def metrics_management_page(): + return FileResponse(f"{metrics_management}/index.html") + + @router.get( + "/metrics-management/{path}", + description="Returns JavaScript and CSS files of the metrics management page", + include_in_schema=False) + async def metrics_management_files(path, request: Request): + if path in ["script.js", "style.css"]: + return FileResponse(f"{metrics_management}/{path}") + sts, msg = "404", "Not Found" + logger.info( + msg=msg, + extra={ + "status": sts, + "method": request.method, + "request_path": request.url.path}) + return f"{sts} {msg}" diff --git a/src/utils/openapi.py b/src/utils/openapi.py index b75645b..d8cd58e 100644 --- a/src/utils/openapi.py +++ b/src/utils/openapi.py @@ -16,7 +16,7 @@ def openapi(app: FastAPI): "providing additional features and addressing its limitations. " "Running as a sidecar alongside the Prometheus server enables " "users to extend the capabilities of the API.", - version="0.3.0", + version="0.3.1", contact={ "name": "Hayk Davtyan", "url": "https://hayk96.github.io", diff --git a/ui/homepage/index.html b/ui/homepage/index.html index ca8c482..69127a9 100644 --- a/ui/homepage/index.html +++ b/ui/homepage/index.html @@ -168,6 +168,7 @@

The easiest Prometheus management interface

+ diff --git a/ui/metrics-management/index.html b/ui/metrics-management/index.html new file mode 100644 index 0000000..fe7e5ba --- /dev/null +++ b/ui/metrics-management/index.html @@ -0,0 +1,67 @@ + + + + + + Metrics Management + + + + +
+
+ + +
+
+ + + + + +
+ + + diff --git a/ui/metrics-management/script.js b/ui/metrics-management/script.js new file mode 100644 index 0000000..ae8df10 --- /dev/null +++ b/ui/metrics-management/script.js @@ -0,0 +1,308 @@ +document.addEventListener('DOMContentLoaded', () => { + setupEventListeners(); + fetchAndDisplayPolicies(); +}); + +function setupEventListeners() { + document.getElementById('createPolicyBtn').addEventListener('click', openCreatePolicyModal); + document.getElementById('submitNewPolicy').addEventListener('click', createPolicy); + document.getElementById('cancelCreatePolicyBtn').addEventListener('click', closeCreatePolicyModal); + document.getElementById('cancelEditPolicyBtn').addEventListener('click', closeEditPolicyModal); + document.getElementById('submitEditPolicy').addEventListener('click', savePolicy); + document.getElementById('confirmDeletePolicyBtn').addEventListener('click', confirmDeletePolicy); + document.getElementById('cancelDeletePolicyBtn').addEventListener('click', closeDeletePolicyModal); + document.getElementById('searchInput').addEventListener('input', handleSearchInput); + document.getElementById('homeBtn').addEventListener('click', () => window.location.href = '/'); + document.getElementById('prometheusBtn').addEventListener('click', () => window.location.href = '/graph'); +} + +let allPolicies = []; +let policyToDelete = null; + +/** + * This function is responsible for retrieving the + * current set of metrics lifecycle policies from + * the server and displaying them on the user interface + */ +function fetchAndDisplayPolicies() { + fetch('/api/v1/metrics-lifecycle-policies') + .then(response => response.json()) + .then(data => { + allPolicies = data; + displayPolicies(data); + }) + .catch(error => console.error('Error fetching policies:', error)); +} + +/** + * This function is responsible for + * rendering the metrics lifecycle + * policies onto the user interface + */ +function displayPolicies(policies) { + const policiesListElement = document.getElementById('policiesList'); + policiesListElement.innerHTML = ''; + + if (Object.keys(policies).length === 0) { + const noPoliciesMessage = document.createElement('div'); + noPoliciesMessage.className = 'no-policies-message'; + noPoliciesMessage.textContent = 'No metrics lifecycle policies are defined'; + policiesListElement.appendChild(noPoliciesMessage); + return; + } + + for (const [name, policy] of Object.entries(policies)) { + const policyItem = document.createElement('div'); + policyItem.className = 'policy-item'; + + const nameDiv = document.createElement('div'); + nameDiv.textContent = name; + nameDiv.className = 'filename'; + policyItem.appendChild(nameDiv); + + const detailsDiv = document.createElement('div'); + detailsDiv.className = 'details'; + + + for (const [key, value] of Object.entries(policy)) { + const fieldDiv = document.createElement('div'); + fieldDiv.className = `field-${key}`; + fieldDiv.innerHTML = `${capitalizeFirstLetter(key)}: ${value}`; + detailsDiv.appendChild(fieldDiv); + } + + policyItem.appendChild(detailsDiv); + + const buttonsContainer = document.createElement('div'); + buttonsContainer.className = 'button-container'; + + const editButton = document.createElement('button'); + editButton.className = 'edit-policy-btn'; + editButton.textContent = 'Edit'; + editButton.addEventListener('click', () => openEditPolicyModal(name, policy)); + buttonsContainer.appendChild(editButton); + + const deleteButton = document.createElement('button'); + deleteButton.className = 'remove-policy-btn'; + deleteButton.textContent = 'Delete'; + deleteButton.addEventListener('click', () => openDeletePolicyModal(name)); + buttonsContainer.appendChild(deleteButton); + + policyItem.appendChild(buttonsContainer); + policiesListElement.appendChild(policyItem); + } +} + +/** + * This function is designed to take a string + * as input and return a new string with the + * first letter capitalized and the rest of + * the string unchanged + */ +function capitalizeFirstLetter(string) { + return string.charAt(0).toUpperCase() + string.slice(1).replace(/_/g, ' '); +} + +/** + * This function is an event handler designed + * to filter and display policies based on + * user input in the search bar + */ +function handleSearchInput(event) { + const searchTerm = event.target.value.toLowerCase(); + const filteredPolicies = {}; + + for (const [name, policy] of Object.entries(allPolicies)) { + if (policy.match.toLowerCase().includes(searchTerm)) { + filteredPolicies[name] = policy; + } + } + + displayPolicies(filteredPolicies); +} + +/** + * This function is designed to handle + * the user interaction for opening the + * modal dialog used to create a new policy + */ +function openCreatePolicyModal() { + document.getElementById('createPolicyModal').style.display = 'block'; +} + +/** + * This function is responsible for handling + * the user interaction to close the create + * policy modal dialog + */ +function closeCreatePolicyModal() { + document.getElementById('createPolicyModal').style.display = 'none'; + clearCreatePolicyForm(); +} + +/** + * This function is designed to reset and clear + * all input fields and error messages within + * the "Create New Policy" modal dialog + */ +function clearCreatePolicyForm() { + document.getElementById('newPolicyName').value = ''; + document.getElementById('newPolicyMatch').value = ''; + document.getElementById('newPolicyKeepFor').value = ''; + document.getElementById('newPolicyDescription').value = ''; + document.getElementById('createPolicyError').textContent = ''; +} + +/** + * This function is responsible for creating a new + * metrics lifecycle policy based on the input + * provided by the user in the "Create New Policy" + * modal dialog + */ +function createPolicy() { + const name = document.getElementById('newPolicyName').value.trim(); + const match = document.getElementById('newPolicyMatch').value.trim(); + const keepFor = document.getElementById('newPolicyKeepFor').value.trim(); + const description = document.getElementById('newPolicyDescription').value.trim(); + + + if (!name || !match || !keepFor) { + document.getElementById('createPolicyError').textContent = 'Policy name, match pattern, and retention period are required.'; + return; + } + + const policy = { name, match, keep_for: keepFor, description }; + + fetch('/api/v1/metrics-lifecycle-policies', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(policy) + }) + .then(response => response.json().then(data => ({ ok: response.ok, data }))) + .then(({ ok, data }) => { + if (ok) { + closeCreatePolicyModal(); + fetchAndDisplayPolicies(); + } else { + throw new Error(data.message || 'An error occurred'); + } + }) + .catch(error => { + document.getElementById('createPolicyError').textContent = `Error creating policy: ${error.message}`; + }); +} + +/** + * This function is responsible for opening + * the modal dialog to edit an existing + * metrics lifecycle policy + */ +function openEditPolicyModal(name, policy) { + document.getElementById('editPolicyName').value = name; + document.getElementById('editPolicyMatch').value = policy.match; + document.getElementById('editPolicyKeepFor').value = policy.keep_for; + document.getElementById('editPolicyDescription').value = policy.description; + document.getElementById('editPolicyModal').style.display = 'block'; +} + +/** + * This function is responsible for closing + * the edit policy modal dialog and clearing + * any input fields within the modal + */ +function closeEditPolicyModal() { + document.getElementById('editPolicyModal').style.display = 'none'; + clearEditPolicyForm(); +} + +/** + * This function is designed to reset the + * input fields and clear any error messages + * in the edit policy modal. + */ +function clearEditPolicyForm() { + document.getElementById('editPolicyName').value = ''; + document.getElementById('editPolicyMatch').value = ''; + document.getElementById('editPolicyKeepFor').value = ''; + document.getElementById('editPolicyDescription').value = ''; + document.getElementById('editPolicyError').textContent = ''; +} + +/** + * This function is responsible for saving + * the changes made to an existing policy + */ +function savePolicy() { + const name = document.getElementById('editPolicyName').value.trim(); + const match = document.getElementById('editPolicyMatch').value.trim(); + const keepFor = document.getElementById('editPolicyKeepFor').value.trim(); + const description = document.getElementById('editPolicyDescription').value.trim(); + + const policy = { match, keep_for: keepFor, description }; + + fetch(`/api/v1/metrics-lifecycle-policies/${name}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(policy) + }) + .then(response => { + if (response.ok) { + closeEditPolicyModal(); + fetchAndDisplayPolicies(); + } else { + return response.json().then(data => { + throw new Error(data.message); + }); + } + }) + .catch(error => { + document.getElementById('editPolicyError').textContent = `Error saving policy: ${error.message}`; + }); +} + +/** + * This function is responsible for opening the delete + * policy modal and setting up the necessary information + * to confirm the deletion of a specified policy + */ +function openDeletePolicyModal(name) { + policyToDelete = name; + document.getElementById('deletePolicyModal').style.display = 'block'; +} + +/** + * This function is responsible for closing + * the delete policy modal and resetting + * any relevant state or information. + */ +function closeDeletePolicyModal() { + document.getElementById('deletePolicyModal').style.display = 'none'; + policyToDelete = null; +} + +/** + * This function is responsible for deleting a policy + * when the user confirms the deletion action + */ +function confirmDeletePolicy() { + if (policyToDelete) { + fetch(`/api/v1/metrics-lifecycle-policies/${policyToDelete}`, { + method: 'DELETE' + }) + .then(response => { + if (response.ok) { + closeDeletePolicyModal(); + fetchAndDisplayPolicies(); + } else { + return response.json().then(data => { + throw new Error(data.message); + }); + } + }) + .catch(error => console.error('Error deleting policy:', error)); + } +} diff --git a/ui/metrics-management/style.css b/ui/metrics-management/style.css new file mode 100644 index 0000000..a68ba22 --- /dev/null +++ b/ui/metrics-management/style.css @@ -0,0 +1,399 @@ +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + margin: 0; + padding: 20px; + background-color: #EDF2F7; + color: #333; +} + +.toolbar { + display: flex; + align-items: center; + gap: 15px; + margin-bottom: 30px; + background-color: #fff; + padding: 10px; + border-radius: 30px; + box-shadow: 0 4px 6px rgba(50, 50, 93, 0.11), 0 1px 3px rgba(0, 0, 0, 0.08); + width: calc(50% - 20px); +} + +.action-btn { + padding: 8px 25px; + background-color: #48BB78; + color: white; + border: none; + border-radius: 20px; + cursor: pointer; + font-size: 16px; + font-weight: bold; + transition: background-color 0.3s ease; +} + +.action-btn:hover { + background-color: #38A169; +} + +.gray-btn { + background-color: #838080; + color: #fff; +} + +.save-btn { + background-color: #4CAF50; + color: white; +} + +.search-bar { + flex-grow: 1; + padding: 10px 20px; + border-radius: 20px; + border: none; + background-color: #F7FAFC; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.075); +} + +.search-bar::placeholder { + color: #A0AEC0; +} + +.policies-list { + margin: 0; + padding: 0; + list-style-type: none; + display: flex; + flex-wrap: wrap; + gap: 20px; +} + +.policy-item { + background-color: #fff; + border-radius: 8px; + margin-bottom: 8px; + padding: 15px; + display: flex; + flex-direction: column; + justify-content: space-between; + box-shadow: 0 1px 3px rgba(50, 50, 93, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06); + font-size: 0.85rem; + line-height: 1.4; + transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; + width: calc(50% - 20px); + word-wrap: break-word; + overflow: hidden; + max-height: 200px; + overflow-y: auto; +} + +.policy-item:hover { + transform: translateY(-2px); + box-shadow: 0 4px 6px rgba(50, 50, 93, 0.11), 0 2px 4px rgba(0, 0, 0, 0.08); +} + +.filename { + font-size: 1rem; + font-weight: bold; + margin-bottom: 8px; + color: #2D3748; +} + +.details { + font-size: 0.9rem; + color: #4A5568; +} + +.details div { + margin-bottom: 5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.details div:hover { + white-space: normal; +} + + +.field-name { + font-weight: bold; +} + +.button-container { + display: flex; + gap: 10px; + align-items: center; + margin-top: 10px; +} + +.edit-policy-btn, .remove-policy-btn { + padding: 5px 10px; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s ease-in-out; +} + +.edit-policy-btn { + background-color: #007BFF; + color: white; +} + +.edit-policy-btn:hover { + background-color: #0056b3; +} + +.remove-policy-btn { + background-color: #f44336; + color: white; +} + +.remove-policy-btn:hover { + background-color: #d32f2f; +} + +.editor-container { + margin-top: 20px; +} + +.editor-actions { + margin-top: 10px; +} + +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0,0,0,0.4); +} + +.modal-content { + background-color: #fff; + padding: 20px; + border-radius: 10px; + width: auto; + max-width: 500px; + box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2); + text-align: center; + position: relative; + margin: 15% auto; +} + +.modal-content h2 { + color: #333; + margin-bottom: 20px; +} + +.modal-content textarea.modal-input { + width: calc(100% - 40px); + padding: 10px 20px; + margin-bottom: 20px; + border: 1px solid #ccc; + border-radius: 4px; + box-sizing: border-box; + font-size: 16px; + box-shadow: inset 0 2px 4px rgba(0,0,0,0.1); + resize: vertical; + max-height: 150px; +} + +.modal-input { + width: calc(100% - 40px); + padding: 10px 20px; + margin-bottom: 20px; + border: 1px solid #ccc; + border-radius: 4px; + box-sizing: border-box; + font-size: 16px; + box-shadow: inset 0 2px 4px rgba(0,0,0,0.1); +} + +.modal-error-message { + color: #f44336; + margin-top: 10px; +} + +.modal-btn { + padding: 10px 20px; + margin: 10px 5px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 16px; + transition: background-color 0.2s ease-in-out; + outline: none; +} + +.modal-btn-confirm { + background-color: #48BB78; + color: white; +} + +.modal-btn-confirm.delete-btn { + background-color: #f44336; +} + +.modal-btn-confirm.delete-btn:hover { + background-color: #d32f2f; +} + +.modal-btn-cancel { + background-color: #838080; + color: white; +} + +.modal-btn-cancel:hover { + background-color: #707070; +} + + +.modal-btn-create { + background-color: #48BB78; + color: white; +} + +.modal-btn-create:hover { + background-color: #38A169; +} + + +.modal-btn-cancel-create { + background-color: #838080; + color: white; +} + +.modal-btn-cancel-create:hover { + background-color: #707070; +} + +.close { + display: none; +} + +.close { + color: #333; + float: right; + font-size: 32px; + font-weight: bold; +} + +.close:hover, +.close:focus { + color: #000; + text-decoration: none; + cursor: pointer; +} + +.main-content { + margin-left: 60px; + transition: margin-left 0.3s ease; +} + +.sidebar:hover + .main-content { + margin-left: 200px; +} + +.sidebar { + position: fixed; + left: 0; + top: 0; + height: 100%; + width: 60px; + background-color: #30354b; + transition: width 0.3s ease; + z-index: 1001; +} + +.sidebar:hover { + width: 200px; +} + +.sidebar-icon { + display: flex; + align-items: center; + padding: 20px; + color: white; + text-decoration: none; + overflow: hidden; + white-space: nowrap; +} + +.sidebar-icon img { + width: 30px; + transition: transform 0.2s ease; +} + +.icon-label { + margin-left: 10px; + display: none; + transition: opacity 0.2s ease; +} + +.sidebar:hover .icon-label { + display: inline; + opacity: 1; +} + +@media (max-width: 768px) { + .toolbar { + flex-direction: column; + align-items: stretch; + width: 100%; + } + + .search-bar { + margin-top: 10px; + width: auto; + } + + .action-btn { + width: 100%; + margin-bottom: 10px; + } + + .policy-item { + flex-direction: column; + align-items: flex-start; + } + + .filename, + .button-container, + .policy-type { + width: 100%; + margin-bottom: 10px; + } + + .button-container { + flex-direction: row; + justify-content: flex-start; + gap: 10px; + } + + .policy-type { + order: 3; + margin-left: 0; + } + + .sidebar { + width: 60px; + } + + .sidebar:hover { + width: 60px; + } + + .main-content { + margin-left: 60px; + } +} + +.no-policies-message { + padding: 8px; + color: #ab1423; + font-weight: bold; + text-align: center; +} diff --git a/ui/rules-management/index.html b/ui/rules-management/index.html index 38d23f8..7223ddb 100644 --- a/ui/rules-management/index.html +++ b/ui/rules-management/index.html @@ -18,15 +18,29 @@ Prometheus Prometheus + + Metrics Management + Metrics Management +
+
+ + +
+