Skip to content

Commit

Permalink
Release v0.4.1 (#46)
Browse files Browse the repository at this point in the history
* feature(ui): Add Reporting page web UI (#43) #patch

Signed-off-by: hayk96 <[email protected]>

---------

Signed-off-by: hayk96 <[email protected]>
  • Loading branch information
hayk96 authored Jun 30, 2024
1 parent 3f4e3fe commit 25da625
Show file tree
Hide file tree
Showing 13 changed files with 631 additions and 24 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 0.4.1 / 2024-06-30

* [ENHANCEMENT] Added a new web page for reports. This page allows exporting Prometheus data in various formats directly from the web UI. #43
* [ENHANCEMENT] Added functionality to change the timestamp format while exporting data via the /export API. Previously, the default value was Unix timestamp. Now, you can choose from the following options: iso8601, rfc2822, rfc3339, friendly, and unix (default). #41
* [ENHANCEMENT] Added a new feature that allows replacing Prometheus labels (fields) in the final dataset: CSV, JSON, etc. #39
* [ENHANCEMENT] Added support for exporting files in multiple formats via the /export API. Supported formats include: CSV, YAML (or YML), JSON, and JSON Lines (or NDJSON). E.g., ?format=csv|yaml|yml|json|ndjson|jsonlines. #37
* [ENHANCEMENT] Improved the functionality that generates CSV files to ensure they have unique names instead of static names, resolving issues with responses getting mixed up between users. #35
* [BUGFIX] Fixed exception handling for replace_fields in the /export API. #43

## 0.4.0 / 2024-06-23

* [ENHANCEMENT] Added a new API endpoint: `/export` for exporting data from Prometheus as a CSV file. This feature allows users to export data from Prometheus easily.
Expand Down
20 changes: 12 additions & 8 deletions src/api/v1/endpoints/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,15 @@ async def export(
Body(
openapi_examples=ExportData._request_body_examples,
)
]
],
format: str = "csv"
):
data = data.dict()
filename = "data.csv"
expr, start = data.get("expr"), data.get("start")
end, step = data.get("end"), data.get("step")
file, file_format = None, format.lower()
custom_fields, timestamp_format = data.get(
"replace_fields"), data.get("timestamp_format")
validation_status, response.status_code, sts, msg = exp.validate_request(
"export.json", data)
if validation_status:
Expand All @@ -79,9 +82,10 @@ async def export(
query=expr, start=start,
end=end, step=step)
if resp_status:
labels, data_processed = exp.data_processor(source_data=resp_data)
csv_generator_status, sts, msg = exp.csv_generator(
data=data_processed, fields=labels, filename=filename)
labels, data_processed = exp.data_processor(
source_data=resp_data, custom_fields=custom_fields, timestamp_format=timestamp_format)
file_generator_status, sts, response.status_code, file, msg = exp.file_generator(
file_format=file_format, data=data_processed, fields=labels)
else:
sts, msg = resp_data.get("status"), resp_data.get("error")

Expand All @@ -91,8 +95,8 @@ async def export(
"status": response.status_code,
"query": expr,
"method": request.method,
"request_path": request.url.path})
"request_path": f"{request.url.path}{'?' + request.url.query if request.url.query else ''}"})
if sts == "success":
return FileResponse(path=filename,
background=BackgroundTask(exp.cleanup_files, filename))
return FileResponse(path=file,
background=BackgroundTask(exp.cleanup_files, file))
return {"status": sts, "query": expr, "message": msg}
25 changes: 24 additions & 1 deletion src/api/v1/endpoints/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
if arg_parser().get("web.enable_ui") == "true":
rules_management = "ui/rules-management"
metrics_management = "ui/metrics-management"
reports = "ui/reports"
logger.info("Starting web management UI")

@router.get("/", response_class=HTMLResponse,
Expand Down Expand Up @@ -41,7 +42,7 @@ async def rules_management_files(path, request: Request):
return f"{sts} {msg}"

@router.get("/metrics-management",
description="RRenders metrics management HTML page of this application",
description="Renders metrics management HTML page of this application",
include_in_schema=False)
async def metrics_management_page():
return FileResponse(f"{metrics_management}/index.html")
Expand All @@ -61,3 +62,25 @@ async def metrics_management_files(path, request: Request):
"method": request.method,
"request_path": request.url.path})
return f"{sts} {msg}"

@router.get("/reports",
description="Renders Reports HTML page of this application",
include_in_schema=False)
async def reports_page():
return FileResponse(f"{reports}/index.html")

@router.get(
"/reports/{path}",
description="Returns JavaScript and CSS files of the Reports",
include_in_schema=False)
async def reports_files(path, request: Request):
if path in ["script.js", "style.css"]:
return FileResponse(f"{reports}/{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}"
82 changes: 69 additions & 13 deletions src/core/export.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
from jsonschema import validate, exceptions
from src.utils.arguments import arg_parser
from email.utils import formatdate
from datetime import datetime
from uuid import uuid4
import requests
import json
import yaml
import copy
import csv
import os
Expand Down Expand Up @@ -31,7 +35,41 @@ def prom_query(query, range_query=False, start="0", end="0",
return True if r.status_code == 200 else False, r.status_code, r.json()


def data_processor(source_data: dict) -> tuple[list, list]:
def replace_fields(data, custom_fields) -> None:
"""
This function replaces (renames) the
final Prometheus labels (fields) based
on the 'replace_fields' object.
"""
for source_field, target_field in custom_fields.items():
try:
if isinstance(data, list):
data[data.index(source_field)] = target_field
elif isinstance(data, dict):
data[target_field] = data.pop(source_field)
except (ValueError, KeyError):
pass


def format_timestamp(timestamp, fmt) -> str:
"""
This function converts Unix timestamps
to several common time formats.
"""
timestamp_formats = {
"unix": timestamp,
"rfc2822": formatdate(timestamp, localtime=True),
"iso8601": datetime.fromtimestamp(timestamp).isoformat(),
"rfc3339": datetime.fromtimestamp(timestamp).astimezone().isoformat(timespec='milliseconds'),
"friendly": datetime.fromtimestamp(timestamp).strftime('%A, %B %d, %Y %I:%M:%S %p')
}

return timestamp_formats[fmt]


def data_processor(source_data: dict,
custom_fields: dict,
timestamp_format: str) -> tuple[list, list]:
"""
This function preprocesses the results
of the Prometheus query for future formatting.
Expand All @@ -47,8 +85,10 @@ def vector_processor():
ts_labels = set(ts["metric"].keys())
unique_labels.update(ts_labels)
series = ts["metric"]
series["timestamp"] = ts["value"][0]
series["timestamp"] = format_timestamp(
ts["value"][0], timestamp_format)
series["value"] = ts["value"][1]
replace_fields(series, custom_fields)
data_processed.append(series)

def matrix_processor():
Expand All @@ -58,8 +98,10 @@ def matrix_processor():
series = ts["metric"]
for idx in range(len(ts["values"])):
series_nested = copy.deepcopy(series)
series_nested["timestamp"] = ts["values"][idx][0]
series_nested["timestamp"] = format_timestamp(
ts["values"][idx][0], timestamp_format)
series_nested["value"] = ts["values"][idx][1]
replace_fields(series_nested, custom_fields)
data_processed.append(series_nested)
del series_nested

Expand All @@ -70,6 +112,7 @@ def matrix_processor():

unique_labels = sorted(unique_labels)
unique_labels.extend(["timestamp", "value"])
replace_fields(unique_labels, custom_fields)
return unique_labels, data_processed


Expand Down Expand Up @@ -102,18 +145,31 @@ def cleanup_files(file) -> tuple[True, str]:
return True, "File has been removed successfully"


def csv_generator(data, fields, filename) -> tuple[bool, str, str]:
def file_generator(file_format, data, fields):
"""
This function generates a CSV file
based on the provided objects.
This function generates a file depending
on the provided file format/extension
"""

file_path = f"/tmp/{str(uuid4())}.{file_format}"
try:
with open(filename, 'w') as csvfile:
writer = csv.DictWriter(
csvfile, fieldnames=fields, extrasaction='ignore')
writer.writeheader()
writer.writerows(data)
with open(file_path, 'w') as f:
if file_format == "csv":
writer = csv.DictWriter(
f, fieldnames=fields, extrasaction='ignore')
writer.writeheader()
writer.writerows(data)
elif file_format in ["yml", "yaml"]:
f.write(yaml.dump(data))
elif file_format == "json":
f.write(json.dumps(data))
elif file_format in ["ndjson", "jsonlines"]:
for i in data:
f.write(f"{json.dumps(i)}\n")
else:
cleanup_files(file_path)
return False, "error", 400, "", f"Unsupported file format '{file_format}'"
except BaseException as e:
return False, "error", str(e)
return False, "error", 500, "", str(e)
else:
return True, "success", "CSV file has been generated successfully"
return True, "success", 200, file_path, f"{file_format.upper()} file has been generated successfully"
29 changes: 28 additions & 1 deletion src/models/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,41 @@ class ExportData(BaseModel, extra=Extra.allow):
start: Optional[str] = None
end: Optional[str] = None
step: Optional[str] = None
timestamp_format: Optional[str] = "unix"
replace_fields: Optional[dict] = dict()
_request_body_examples = {
"Count of successful logins by users per hour in a day": {
"User logins per hour in a day": {
"description": "Count of successful logins by users per hour in a day",
"value": {
"expr": "users_login_count{status='success'}",
"start": "2024-01-30T00:00:00Z",
"end": "2024-01-31T23:59:59Z",
"step": "1h"
}
},
"User logins per hour in a day with a user-friendly time format": {
"description": "Count of successful user logins per hour in a day with a user-friendly time format",
"value": {
"expr": "users_login_count{status='success'}",
"start": "2024-01-30T00:00:00Z",
"end": "2024-01-31T23:59:59Z",
"step": "1h",
"timestamp_format": "friendly"
}
},
"User logins per hour with friendly time format and custom fields": {
"description": "Count of successful user logins per hour in a day "
"with a user-friendly time format and custom fields",
"value": {
"expr": "users_login_count{status='success'}",
"start": "2024-01-30T00:00:00Z",
"end": "2024-01-31T23:59:59Z",
"step": "1h",
"timestamp_format": "friendly",
"replace_fields": {
"__name__": "Name",
"timestamp": "Time"
}
}
}
}
7 changes: 7 additions & 0 deletions src/schemas/export.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@
"step": {
"type": ["string", "null"],
"pattern": "^((([0-9]+)y)?(([0-9]+)w)?(([0-9]+)d)?(([0-9]+)h)?(([0-9]+)m)?(([0-9]+)s)?(([0-9]+)ms)?|0)$"
},
"timestamp_format": {
"type": ["string"],
"pattern": "^(unix|iso8601|rfc2822|rfc3339|friendly)$"
},
"replace_fields": {
"type": "object"
}
},
"required": ["expr"],
Expand Down
2 changes: 1 addition & 1 deletion src/utils/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.4.0",
version="0.4.1",
contact={
"name": "Hayk Davtyan",
"url": "https://hayk96.github.io",
Expand Down
5 changes: 5 additions & 0 deletions ui/homepage/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@
color: #ffffff;
background-image: linear-gradient(45deg, #f6d365, #fda085);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16);
width: 200px;
}
button:hover {
animation: buttonPulse 0.5s ease;
Expand Down Expand Up @@ -170,6 +171,7 @@ <h1>The easiest Prometheus management interface</h1>
<button id="openPrometheusButton">Open Prometheus</button>
<button id="rulesManagementButton">Rules Management</button>
<button id="metricsManagementButton">Metrics Management</button>
<button id="reportsButton">Reports</button>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
Expand All @@ -183,6 +185,9 @@ <h1>The easiest Prometheus management interface</h1>
document.getElementById('metricsManagementButton').onclick = function() {
window.location.href = window.location.origin + '/metrics-management';
};
document.getElementById('reportsButton').onclick = function() {
window.location.href = window.location.origin + '/reports';
};
});
</script>
</body>
Expand Down
4 changes: 4 additions & 0 deletions ui/metrics-management/index.html

Large diffs are not rendered by default.

Loading

0 comments on commit 25da625

Please sign in to comment.