Skip to content
This repository was archived by the owner on Jan 13, 2025. It is now read-only.

Commit 25da625

Browse files
authored
Release v0.4.1 (#46)
* feature(ui): Add Reporting page web UI (#43) #patch Signed-off-by: hayk96 <[email protected]> --------- Signed-off-by: hayk96 <[email protected]>
1 parent 3f4e3fe commit 25da625

File tree

13 files changed

+631
-24
lines changed

13 files changed

+631
-24
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## 0.4.1 / 2024-06-30
4+
5+
* [ENHANCEMENT] Added a new web page for reports. This page allows exporting Prometheus data in various formats directly from the web UI. #43
6+
* [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
7+
* [ENHANCEMENT] Added a new feature that allows replacing Prometheus labels (fields) in the final dataset: CSV, JSON, etc. #39
8+
* [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
9+
* [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
10+
* [BUGFIX] Fixed exception handling for replace_fields in the /export API. #43
11+
312
## 0.4.0 / 2024-06-23
413

514
* [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.

src/api/v1/endpoints/export.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,15 @@ async def export(
6464
Body(
6565
openapi_examples=ExportData._request_body_examples,
6666
)
67-
]
67+
],
68+
format: str = "csv"
6869
):
6970
data = data.dict()
70-
filename = "data.csv"
7171
expr, start = data.get("expr"), data.get("start")
7272
end, step = data.get("end"), data.get("step")
73+
file, file_format = None, format.lower()
74+
custom_fields, timestamp_format = data.get(
75+
"replace_fields"), data.get("timestamp_format")
7376
validation_status, response.status_code, sts, msg = exp.validate_request(
7477
"export.json", data)
7578
if validation_status:
@@ -79,9 +82,10 @@ async def export(
7982
query=expr, start=start,
8083
end=end, step=step)
8184
if resp_status:
82-
labels, data_processed = exp.data_processor(source_data=resp_data)
83-
csv_generator_status, sts, msg = exp.csv_generator(
84-
data=data_processed, fields=labels, filename=filename)
85+
labels, data_processed = exp.data_processor(
86+
source_data=resp_data, custom_fields=custom_fields, timestamp_format=timestamp_format)
87+
file_generator_status, sts, response.status_code, file, msg = exp.file_generator(
88+
file_format=file_format, data=data_processed, fields=labels)
8589
else:
8690
sts, msg = resp_data.get("status"), resp_data.get("error")
8791

@@ -91,8 +95,8 @@ async def export(
9195
"status": response.status_code,
9296
"query": expr,
9397
"method": request.method,
94-
"request_path": request.url.path})
98+
"request_path": f"{request.url.path}{'?' + request.url.query if request.url.query else ''}"})
9599
if sts == "success":
96-
return FileResponse(path=filename,
97-
background=BackgroundTask(exp.cleanup_files, filename))
100+
return FileResponse(path=file,
101+
background=BackgroundTask(exp.cleanup_files, file))
98102
return {"status": sts, "query": expr, "message": msg}

src/api/v1/endpoints/web.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
if arg_parser().get("web.enable_ui") == "true":
1111
rules_management = "ui/rules-management"
1212
metrics_management = "ui/metrics-management"
13+
reports = "ui/reports"
1314
logger.info("Starting web management UI")
1415

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

4344
@router.get("/metrics-management",
44-
description="RRenders metrics management HTML page of this application",
45+
description="Renders metrics management HTML page of this application",
4546
include_in_schema=False)
4647
async def metrics_management_page():
4748
return FileResponse(f"{metrics_management}/index.html")
@@ -61,3 +62,25 @@ async def metrics_management_files(path, request: Request):
6162
"method": request.method,
6263
"request_path": request.url.path})
6364
return f"{sts} {msg}"
65+
66+
@router.get("/reports",
67+
description="Renders Reports HTML page of this application",
68+
include_in_schema=False)
69+
async def reports_page():
70+
return FileResponse(f"{reports}/index.html")
71+
72+
@router.get(
73+
"/reports/{path}",
74+
description="Returns JavaScript and CSS files of the Reports",
75+
include_in_schema=False)
76+
async def reports_files(path, request: Request):
77+
if path in ["script.js", "style.css"]:
78+
return FileResponse(f"{reports}/{path}")
79+
sts, msg = "404", "Not Found"
80+
logger.info(
81+
msg=msg,
82+
extra={
83+
"status": sts,
84+
"method": request.method,
85+
"request_path": request.url.path})
86+
return f"{sts} {msg}"

src/core/export.py

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
from jsonschema import validate, exceptions
22
from src.utils.arguments import arg_parser
3+
from email.utils import formatdate
4+
from datetime import datetime
5+
from uuid import uuid4
36
import requests
47
import json
8+
import yaml
59
import copy
610
import csv
711
import os
@@ -31,7 +35,41 @@ def prom_query(query, range_query=False, start="0", end="0",
3135
return True if r.status_code == 200 else False, r.status_code, r.json()
3236

3337

34-
def data_processor(source_data: dict) -> tuple[list, list]:
38+
def replace_fields(data, custom_fields) -> None:
39+
"""
40+
This function replaces (renames) the
41+
final Prometheus labels (fields) based
42+
on the 'replace_fields' object.
43+
"""
44+
for source_field, target_field in custom_fields.items():
45+
try:
46+
if isinstance(data, list):
47+
data[data.index(source_field)] = target_field
48+
elif isinstance(data, dict):
49+
data[target_field] = data.pop(source_field)
50+
except (ValueError, KeyError):
51+
pass
52+
53+
54+
def format_timestamp(timestamp, fmt) -> str:
55+
"""
56+
This function converts Unix timestamps
57+
to several common time formats.
58+
"""
59+
timestamp_formats = {
60+
"unix": timestamp,
61+
"rfc2822": formatdate(timestamp, localtime=True),
62+
"iso8601": datetime.fromtimestamp(timestamp).isoformat(),
63+
"rfc3339": datetime.fromtimestamp(timestamp).astimezone().isoformat(timespec='milliseconds'),
64+
"friendly": datetime.fromtimestamp(timestamp).strftime('%A, %B %d, %Y %I:%M:%S %p')
65+
}
66+
67+
return timestamp_formats[fmt]
68+
69+
70+
def data_processor(source_data: dict,
71+
custom_fields: dict,
72+
timestamp_format: str) -> tuple[list, list]:
3573
"""
3674
This function preprocesses the results
3775
of the Prometheus query for future formatting.
@@ -47,8 +85,10 @@ def vector_processor():
4785
ts_labels = set(ts["metric"].keys())
4886
unique_labels.update(ts_labels)
4987
series = ts["metric"]
50-
series["timestamp"] = ts["value"][0]
88+
series["timestamp"] = format_timestamp(
89+
ts["value"][0], timestamp_format)
5190
series["value"] = ts["value"][1]
91+
replace_fields(series, custom_fields)
5292
data_processed.append(series)
5393

5494
def matrix_processor():
@@ -58,8 +98,10 @@ def matrix_processor():
5898
series = ts["metric"]
5999
for idx in range(len(ts["values"])):
60100
series_nested = copy.deepcopy(series)
61-
series_nested["timestamp"] = ts["values"][idx][0]
101+
series_nested["timestamp"] = format_timestamp(
102+
ts["values"][idx][0], timestamp_format)
62103
series_nested["value"] = ts["values"][idx][1]
104+
replace_fields(series_nested, custom_fields)
63105
data_processed.append(series_nested)
64106
del series_nested
65107

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

71113
unique_labels = sorted(unique_labels)
72114
unique_labels.extend(["timestamp", "value"])
115+
replace_fields(unique_labels, custom_fields)
73116
return unique_labels, data_processed
74117

75118

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

104147

105-
def csv_generator(data, fields, filename) -> tuple[bool, str, str]:
148+
def file_generator(file_format, data, fields):
106149
"""
107-
This function generates a CSV file
108-
based on the provided objects.
150+
This function generates a file depending
151+
on the provided file format/extension
109152
"""
153+
154+
file_path = f"/tmp/{str(uuid4())}.{file_format}"
110155
try:
111-
with open(filename, 'w') as csvfile:
112-
writer = csv.DictWriter(
113-
csvfile, fieldnames=fields, extrasaction='ignore')
114-
writer.writeheader()
115-
writer.writerows(data)
156+
with open(file_path, 'w') as f:
157+
if file_format == "csv":
158+
writer = csv.DictWriter(
159+
f, fieldnames=fields, extrasaction='ignore')
160+
writer.writeheader()
161+
writer.writerows(data)
162+
elif file_format in ["yml", "yaml"]:
163+
f.write(yaml.dump(data))
164+
elif file_format == "json":
165+
f.write(json.dumps(data))
166+
elif file_format in ["ndjson", "jsonlines"]:
167+
for i in data:
168+
f.write(f"{json.dumps(i)}\n")
169+
else:
170+
cleanup_files(file_path)
171+
return False, "error", 400, "", f"Unsupported file format '{file_format}'"
116172
except BaseException as e:
117-
return False, "error", str(e)
173+
return False, "error", 500, "", str(e)
118174
else:
119-
return True, "success", "CSV file has been generated successfully"
175+
return True, "success", 200, file_path, f"{file_format.upper()} file has been generated successfully"

src/models/export.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,41 @@ class ExportData(BaseModel, extra=Extra.allow):
77
start: Optional[str] = None
88
end: Optional[str] = None
99
step: Optional[str] = None
10+
timestamp_format: Optional[str] = "unix"
11+
replace_fields: Optional[dict] = dict()
1012
_request_body_examples = {
11-
"Count of successful logins by users per hour in a day": {
13+
"User logins per hour in a day": {
1214
"description": "Count of successful logins by users per hour in a day",
1315
"value": {
1416
"expr": "users_login_count{status='success'}",
1517
"start": "2024-01-30T00:00:00Z",
1618
"end": "2024-01-31T23:59:59Z",
1719
"step": "1h"
1820
}
21+
},
22+
"User logins per hour in a day with a user-friendly time format": {
23+
"description": "Count of successful user logins per hour in a day with a user-friendly time format",
24+
"value": {
25+
"expr": "users_login_count{status='success'}",
26+
"start": "2024-01-30T00:00:00Z",
27+
"end": "2024-01-31T23:59:59Z",
28+
"step": "1h",
29+
"timestamp_format": "friendly"
30+
}
31+
},
32+
"User logins per hour with friendly time format and custom fields": {
33+
"description": "Count of successful user logins per hour in a day "
34+
"with a user-friendly time format and custom fields",
35+
"value": {
36+
"expr": "users_login_count{status='success'}",
37+
"start": "2024-01-30T00:00:00Z",
38+
"end": "2024-01-31T23:59:59Z",
39+
"step": "1h",
40+
"timestamp_format": "friendly",
41+
"replace_fields": {
42+
"__name__": "Name",
43+
"timestamp": "Time"
44+
}
45+
}
1946
}
2047
}

src/schemas/export.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@
1616
"step": {
1717
"type": ["string", "null"],
1818
"pattern": "^((([0-9]+)y)?(([0-9]+)w)?(([0-9]+)d)?(([0-9]+)h)?(([0-9]+)m)?(([0-9]+)s)?(([0-9]+)ms)?|0)$"
19+
},
20+
"timestamp_format": {
21+
"type": ["string"],
22+
"pattern": "^(unix|iso8601|rfc2822|rfc3339|friendly)$"
23+
},
24+
"replace_fields": {
25+
"type": "object"
1926
}
2027
},
2128
"required": ["expr"],

src/utils/openapi.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def openapi(app: FastAPI):
1616
"providing additional features and addressing its limitations. "
1717
"Running as a sidecar alongside the Prometheus server enables "
1818
"users to extend the capabilities of the API.",
19-
version="0.4.0",
19+
version="0.4.1",
2020
contact={
2121
"name": "Hayk Davtyan",
2222
"url": "https://hayk96.github.io",

ui/homepage/index.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@
117117
color: #ffffff;
118118
background-image: linear-gradient(45deg, #f6d365, #fda085);
119119
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16);
120+
width: 200px;
120121
}
121122
button:hover {
122123
animation: buttonPulse 0.5s ease;
@@ -170,6 +171,7 @@ <h1>The easiest Prometheus management interface</h1>
170171
<button id="openPrometheusButton">Open Prometheus</button>
171172
<button id="rulesManagementButton">Rules Management</button>
172173
<button id="metricsManagementButton">Metrics Management</button>
174+
<button id="reportsButton">Reports</button>
173175
</div>
174176
<script>
175177
document.addEventListener('DOMContentLoaded', function() {
@@ -183,6 +185,9 @@ <h1>The easiest Prometheus management interface</h1>
183185
document.getElementById('metricsManagementButton').onclick = function() {
184186
window.location.href = window.location.origin + '/metrics-management';
185187
};
188+
document.getElementById('reportsButton').onclick = function() {
189+
window.location.href = window.location.origin + '/reports';
190+
};
186191
});
187192
</script>
188193
</body>

ui/metrics-management/index.html

Lines changed: 4 additions & 0 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)