Skip to content

Commit 5f4fc9e

Browse files
committed
[#84613] Add web UI for managing local device
Signed-off-by: Maciej Sobkowski <msobkowski@antmicro.com>
1 parent fbfd022 commit 5f4fc9e

File tree

10 files changed

+596
-1
lines changed

10 files changed

+596
-1
lines changed

protoplaster/protoplaster.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import shutil
88

99
import protoplaster.api.v1
10+
import protoplaster.webui
1011
from protoplaster.conf.consts import CONFIG_DIR, ARTIFACTS_DIR, REPORTS_DIR
1112
from protoplaster.runner.manager import RunManager
1213
from protoplaster.runner.runner import list_tests, list_test_suites, run_tests
@@ -115,6 +116,7 @@ def run_server(args):
115116
app.config["ARGS"] = args
116117
app.config["RUN_MANAGER"] = RunManager()
117118
app.register_blueprint(protoplaster.api.v1.create_routes())
119+
app.register_blueprint(protoplaster.webui.webui_blueprint)
118120
app.run(port=int(args.port))
119121

120122

protoplaster/webui/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from flask import Blueprint
2+
3+
webui_blueprint = Blueprint(
4+
"webui",
5+
__name__,
6+
template_folder="templates",
7+
url_prefix="/",
8+
)

protoplaster/webui/devices.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
_devices = [{"name": "Local device", "url": "http://127.0.0.1:5000"}]
2+
3+
4+
def get_all_devices():
5+
return list(_devices)
6+
7+
8+
def get_device_by_name(device_name):
9+
return next((d for d in _devices if d["name"] == device_name), None)
10+
11+
12+
def add_device(name, url):
13+
if any(d["name"] == name for d in _devices):
14+
raise ValueError(f"Device '{name}' already exists")
15+
device = {"name": name, "url": url}
16+
_devices.append(device)
17+
return device
18+
19+
20+
def remove_device(device_name):
21+
global _devices
22+
if device_name == "Local device":
23+
raise ValueError("Cannot remove the local device")
24+
before = len(_devices)
25+
_devices = [d for d in _devices if d["name"] != device_name]
26+
return len(_devices) < before

protoplaster/webui/templates/base.html

Lines changed: 172 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
{% extends "base.html" %}
2+
{% block content %}
3+
<h2>Configs</h2>
4+
5+
<div class="d-flex justify-content-end mb-3">
6+
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addConfigModal">
7+
<i class="bi bi-plus"></i> Add new config
8+
</button>
9+
</div>
10+
11+
<table class="table table-bordered device-table">
12+
<thead class="table-light">
13+
<tr>
14+
<th>Name</th>
15+
<th style="width: 20%">Actions</th>
16+
</tr>
17+
</thead>
18+
<tbody>
19+
{% for cfg in configs %}
20+
<tr>
21+
<td>{{ cfg.name }}</td>
22+
<td>
23+
<button class="btn btn-remove btn-sm"><i class="bi bi-trash"></i> Remove</button>
24+
<button class="btn btn-download btn-sm"><i class="bi bi-download"></i> Download</button>
25+
</td>
26+
</tr>
27+
{% else %}
28+
<tr><td colspan="3" class="text-center text-muted">No configs found</td></tr>
29+
{% endfor %}
30+
</tbody>
31+
</table>
32+
33+
<div class="modal fade" id="addConfigModal" tabindex="-1" aria-hidden="true">
34+
<div class="modal-dialog">
35+
<div class="modal-content">
36+
<form id="uploadForm" enctype="multipart/form-data">
37+
<div class="modal-header">
38+
<h5 class="modal-title">Add New Config</h5>
39+
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
40+
</div>
41+
<div class="modal-body">
42+
<div class="mb-3">
43+
<label for="configFile" class="form-label">Config file</label>
44+
<input type="file" id="configFile" name="file" class="form-control" required />
45+
</div>
46+
</div>
47+
<div class="modal-footer">
48+
<button type="submit" class="btn btn-primary">Upload</button>
49+
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
50+
</div>
51+
</form>
52+
</div>
53+
</div>
54+
</div>
55+
56+
<script>
57+
document.querySelectorAll(".btn-remove").forEach(btn => {
58+
btn.addEventListener("click", async e => {
59+
const row = e.target.closest("tr");
60+
const name = row.querySelector("td").textContent.trim();
61+
if (!confirm(`Delete config "${name}"?`)) return;
62+
const resp = await fetch(`/api/v1/configs/${name}`, { method: "DELETE" });
63+
if (resp.ok) {
64+
location.reload();
65+
} else {
66+
alert("Failed to delete config");
67+
}
68+
});
69+
});
70+
71+
document.getElementById("uploadForm").addEventListener("submit", async e => {
72+
e.preventDefault();
73+
const formData = new FormData(e.target);
74+
const resp = await fetch("/api/v1/configs", {
75+
method: "POST",
76+
body: formData
77+
});
78+
if (resp.ok) {
79+
const modal = bootstrap.Modal.getInstance(document.getElementById("addConfigModal"));
80+
modal.hide();
81+
location.reload();
82+
} else {
83+
alert("Upload failed");
84+
}
85+
});
86+
87+
document.querySelectorAll(".btn-download").forEach(btn => {
88+
btn.addEventListener("click", async e => {
89+
const row = e.target.closest("tr");
90+
const name = row.querySelector("td").textContent.trim();
91+
92+
const resp = await fetch(`/api/v1/configs/${name}/file`);
93+
if (!resp.ok) {
94+
alert("Failed to download config file");
95+
return;
96+
}
97+
98+
const blob = await resp.blob();
99+
const url = window.URL.createObjectURL(blob);
100+
const a = document.createElement("a");
101+
a.href = url;
102+
a.download = name;
103+
document.body.appendChild(a);
104+
a.click();
105+
a.remove();
106+
window.URL.revokeObjectURL(url);
107+
});
108+
});
109+
</script>
110+
{% endblock %}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{% extends "base.html" %}
2+
3+
{% block content %}
4+
<h2>Devices</h2>
5+
6+
<table class="table table-bordered device-table">
7+
<thead class="table-light">
8+
<tr>
9+
<th>Name</th>
10+
<th>API URL</th>
11+
<th style="width: 20%">Actions</th>
12+
</tr>
13+
</thead>
14+
<tbody>
15+
{% for device in devices %}
16+
<tr>
17+
<td>{{ device.name }}</td>
18+
<td>{{ device.url }}</td>
19+
<td>
20+
{% if device.name != 'Local device' %}
21+
<form method="post" action="{{ url_for('webui.remove_device_route', device_name=device.name) }}" style="display:inline;">
22+
<button class="btn btn-danger btn-sm" type="submit">
23+
<i class="bi bi-trash"></i> Remove
24+
</button>
25+
</form>
26+
{% else %}
27+
<span class="text-muted">-</span>
28+
{% endif %}
29+
</td>
30+
</tr>
31+
{% else %}
32+
<tr><td colspan="3" class="text-center text-muted">No devices found</td></tr>
33+
{% endfor %}
34+
</tbody>
35+
</table>
36+
37+
{% endblock %}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
{% extends "base.html" %}
2+
{% block content %}
3+
<h2>Test runs</h2>
4+
5+
<div class="d-flex justify-content-end mb-3">
6+
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#triggerTestRunModal">
7+
<i class="bi bi-play-fill"></i> Trigger test run
8+
</button>
9+
</div>
10+
11+
<table class="table table-bordered device-table">
12+
<thead class="table-light">
13+
<tr>
14+
<th>ID</th>
15+
<th>Config</th>
16+
<th>Status</th>
17+
<th>Started</th>
18+
<th>Finished</th>
19+
<th style="width: 20%">Actions</th>
20+
</tr>
21+
</thead>
22+
<tbody>
23+
{% for run in runs %}
24+
<tr>
25+
<td>{{ run.id }}</td>
26+
<td>{{ run.config_name }}</td>
27+
<td>
28+
{% if run.status == 'finished' %}
29+
<span class="badge bg-success">Passed</span>
30+
{% elif run.status == 'failed' %}
31+
<span class="badge bg-danger">Failed</span>
32+
{% elif run.status == 'pending' %}
33+
<span class="badge bg-secondary">Pending</span>
34+
{% elif run.status == 'running' %}
35+
<span class="badge bg-warning text-dark">Running</span>
36+
{% elif run.status == 'aborted' %}
37+
<span class="badge bg-secondary">Aborted</span>
38+
{% endif %}
39+
</td>
40+
<td>{{ run.started_at or '-' }}</td>
41+
<td>{{ run.finished_at or '-' }}</td>
42+
<td>
43+
<button class="btn btn-sm btn-outline-primary btn-details" data-run-id="{{ run.id }}">Details</button>
44+
<button class="btn btn-sm btn-outline-danger btn-abort" data-run-id="{{ run.id }}" {% if run.status not in ['pending'] %}disabled{% endif %}>Abort</button>
45+
</td>
46+
</tr>
47+
{% else %}
48+
<tr><td colspan="6" class="text-center text-muted">No test runs yet</td></tr>
49+
{% endfor %}
50+
</tbody>
51+
</table>
52+
53+
<!-- Trigger Test Run Modal -->
54+
<div class="modal fade" id="triggerTestRunModal" tabindex="-1" aria-labelledby="triggerTestRunModalLabel" aria-hidden="true">
55+
<div class="modal-dialog">
56+
<div class="modal-content">
57+
<form id="triggerRunForm">
58+
<div class="modal-header">
59+
<h5 class="modal-title" id="triggerTestRunModalLabel">Trigger new test run</h5>
60+
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
61+
</div>
62+
<div class="modal-body">
63+
<div class="mb-3">
64+
<label for="configSelect" class="form-label">Configuration</label>
65+
<select id="configSelect" class="form-select" required>
66+
{% for cfg in configs %}
67+
<option value="{{ cfg.name }}">{{ cfg.name }}</option>
68+
{% endfor %}
69+
</select>
70+
</div>
71+
<div class="mb-3">
72+
<label for="testSuiteName" class="form-label">Test suite</label>
73+
<input type="text" name="name" class="form-control" id="testSuiteName">
74+
</div>
75+
</div>
76+
<div class="modal-footer">
77+
<button type="submit" class="btn btn-primary">Trigger</button>
78+
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
79+
</div>
80+
</form>
81+
</div>
82+
</div>
83+
</div>
84+
85+
<!-- Test Details Modal -->
86+
<div class="modal fade" id="testDetailsModal" tabindex="-1" aria-labelledby="testDetailsModalLabel" aria-hidden="true">
87+
<div class="modal-dialog modal-lg">
88+
<div class="modal-content">
89+
<div class="modal-header">
90+
<h5 class="modal-title" id="testDetailsModalLabel">Test Run Details</h5>
91+
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
92+
</div>
93+
<div class="modal-body">
94+
<dl class="row">
95+
<dt class="col-sm-3">Run ID</dt><dd class="col-sm-9" id="detail-id">-</dd>
96+
<dt class="col-sm-3">Config</dt><dd class="col-sm-9" id="detail-config">-</dd>
97+
<dt class="col-sm-3">Status</dt><dd class="col-sm-9" id="detail-status">-</dd>
98+
<dt class="col-sm-3">Start time</dt><dd class="col-sm-9" id="detail-start">-</dd>
99+
<dt class="col-sm-3">Finish time</dt><dd class="col-sm-9" id="detail-end">-</dd>
100+
</dl>
101+
<h6>Artifacts</h6>
102+
<ul id="artifact-list" class="list-group mb-3">
103+
<li class="list-group-item text-muted">No artifacts</li>
104+
</ul>
105+
<div class="text-end">
106+
<a id="report-link" href="#" class="btn btn-outline-secondary" target="_blank">
107+
<i class="bi bi-file-earmark-text"></i> Download CSV Report
108+
</a>
109+
</div>
110+
</div>
111+
</div>
112+
</div>
113+
</div>
114+
115+
<script>
116+
document.addEventListener("DOMContentLoaded", function () {
117+
// Trigger run form
118+
document.getElementById('triggerRunForm').addEventListener('submit', async (e) => {
119+
e.preventDefault();
120+
const config = document.getElementById('configSelect').value;
121+
const testSuite = document.getElementById('testSuiteName').value;
122+
let payload = { config_name: config, test_suite_name: testSuite};
123+
124+
const resp = await fetch('/api/v1/test-runs', {
125+
method: 'POST',
126+
headers: { 'Content-Type': 'application/json' },
127+
body: JSON.stringify(payload)
128+
});
129+
130+
if (resp.ok) location.reload();
131+
else alert('Failed to trigger test run');
132+
});
133+
134+
// Details modal
135+
document.querySelectorAll('.btn-details').forEach(btn => {
136+
btn.addEventListener('click', async (e) => {
137+
const id = e.target.closest("button").dataset.runId;
138+
const resp = await fetch(`/api/v1/test-runs/${id}`);
139+
if (!resp.ok) return alert('Failed to fetch run details');
140+
const data = await resp.json();
141+
142+
const resp_art = await fetch(`/api/v1/test-runs/${id}/artifacts`);
143+
if (!resp_art.ok) return alert('Failed to fetch run artifacts');
144+
const data_art = await resp_art.json();
145+
146+
document.getElementById('detail-id').textContent = data.id;
147+
document.getElementById('detail-config').textContent = data.config_name;
148+
document.getElementById('detail-status').textContent = data.status;
149+
document.getElementById('detail-start').textContent = data.started_at || '-';
150+
document.getElementById('detail-end').textContent = data.finished_at || '-';
151+
152+
// Populate artifacts
153+
const artifactList = document.getElementById('artifact-list');
154+
artifactList.innerHTML = '';
155+
if (data_art && data_art.length > 0) {
156+
data_art.forEach(artifact => {
157+
const li = document.createElement('li');
158+
li.className = 'list-group-item d-flex justify-content-between align-items-center';
159+
li.textContent = artifact.name;
160+
const a = document.createElement('a');
161+
a.href = `/api/v1/test-runs/${id}/artifacts/${encodeURIComponent(artifact.name)}`;
162+
a.className = 'btn btn-sm btn-outline-primary';
163+
a.textContent = 'Download';
164+
li.appendChild(a);
165+
artifactList.appendChild(li);
166+
});
167+
} else {
168+
artifactList.innerHTML = '<li class="list-group-item text-muted">No artifacts</li>';
169+
}
170+
171+
// Report link
172+
const reportLink = document.getElementById('report-link');
173+
reportLink.href = `/api/v1/test-runs/${id}/report`;
174+
175+
new bootstrap.Modal(document.getElementById('testDetailsModal')).show();
176+
});
177+
});
178+
});
179+
</script>
180+
{% endblock %}
181+

0 commit comments

Comments
 (0)