Skip to content

Commit b0925a7

Browse files
authored
Merge pull request #76 from manavgup/feature/version-env-info
Add a new tab for version and environment info
2 parents 1112fb7 + 26ec36c commit b0925a7

File tree

5 files changed

+266
-23
lines changed

5 files changed

+266
-23
lines changed

mcpgateway/static/admin.js

Lines changed: 122 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,61 @@ document.addEventListener("DOMContentLoaded", function () {
2525
document.getElementById("tab-metrics").addEventListener("click", () => {
2626
showTab("metrics");
2727
});
28+
document.getElementById("tab-version-info").addEventListener("click", () => {
29+
showTab("version-info");
30+
});
31+
32+
/* ------------------------------------------------------------------
33+
* Pre-load the "Version & Environment Info" partial once per page
34+
* ------------------------------------------------------------------ */
35+
/* Pre-load version-info once */
36+
document.addEventListener("DOMContentLoaded", () => {
37+
const panel = document.getElementById("version-info-panel");
38+
if (!panel || panel.innerHTML.trim() !== "") return; // already loaded
39+
40+
fetch(`${window.ROOT_PATH}/version?partial=true`)
41+
.then((response) => {
42+
if (!response.ok) throw new Error("Network response was not ok");
43+
return response.text();
44+
})
45+
.then((html) => {
46+
panel.innerHTML = html;
47+
48+
// If the page was opened at #version-info, show that tab now
49+
if (window.location.hash === "#version-info") {
50+
showTab("version-info");
51+
}
52+
})
53+
.catch((error) => {
54+
console.error("Failed to preload version info:", error);
55+
panel.innerHTML =
56+
"<p class='text-red-600'>Failed to load version info.</p>";
57+
});
58+
});
59+
60+
/* ------------------------------------------------------------------
61+
* HTMX debug hooks
62+
* ------------------------------------------------------------------ */
63+
document.body.addEventListener("htmx:afterSwap", (event) => {
64+
if (event.detail.target.id === "version-info-panel") {
65+
console.log("HTMX: Content swapped into version-info-panel");
66+
}
67+
});
68+
69+
70+
// HTMX event listeners for debugging
71+
document.body.addEventListener("htmx:beforeRequest", (event) => {
72+
if (event.detail.elt.id === "tab-version-info") {
73+
console.log("HTMX: Sending request for version info partial");
74+
}
75+
});
76+
77+
document.body.addEventListener("htmx:afterSwap", (event) => {
78+
if (event.detail.target.id === "version-info-panel") {
79+
console.log("HTMX: Content swapped into version-info-panel");
80+
}
81+
});
82+
2883
// Authentication toggle
2984
document.getElementById("auth-type").addEventListener("change", function () {
3085
const basicFields = document.getElementById("auth-basic-fields");
@@ -110,6 +165,7 @@ document.addEventListener("DOMContentLoaded", function () {
110165
status.classList.add("error-status");
111166
} else {
112167
location.reload();
168+
console.log(response);
113169
}
114170
})
115171
.catch((error) => {
@@ -266,6 +322,7 @@ document.addEventListener("DOMContentLoaded", function () {
266322
REST: ["GET", "POST", "PUT", "DELETE"],
267323
};
268324

325+
269326
// Optionally pass in a pre-selected method
270327
function updateEditToolRequestTypes(selectedMethod = null) {
271328
const selectedType = editToolTypeSelect.value;
@@ -334,6 +391,27 @@ function showTab(tabName) {
334391
if (tabName === "metrics") {
335392
loadAggregatedMetrics();
336393
}
394+
395+
if (tabName === "version-info") {
396+
const panel = document.getElementById("version-info-panel");
397+
if (panel && panel.innerHTML.trim() === "") {
398+
const url = `${window.ROOT_PATH}/version?partial=true`;
399+
fetch(url)
400+
.then((response) => {
401+
if (!response.ok) {
402+
throw new Error("Network response was not ok");
403+
}
404+
return response.text();
405+
})
406+
.then((html) => {
407+
panel.innerHTML = html;
408+
})
409+
.catch((error) => {
410+
console.error("Failed to load version info:", error);
411+
panel.innerHTML = "<p class='text-red-600'>Failed to load version info.</p>";
412+
});
413+
}
414+
}
337415
}
338416

339417
// handle auth type selection
@@ -406,7 +484,9 @@ function updateSchemaPreview() {
406484
}
407485
}
408486

409-
// Refresh CodeMirror every time Direct JSON Input is selected
487+
/* ---------------------------------------------------------------
488+
* Switch between "UI-builder" and "JSON input" modes
489+
* ------------------------------------------------------------- */
410490
Array.from(schemaModeRadios).forEach((radio) => {
411491
radio.addEventListener("change", () => {
412492
if (radio.value === "ui" && radio.checked) {
@@ -415,25 +495,11 @@ Array.from(schemaModeRadios).forEach((radio) => {
415495
} else if (radio.value === "json" && radio.checked) {
416496
uiBuilderDiv.style.display = "none";
417497
jsonInputContainer.style.display = "block";
418-
updateSchemaPreview();
498+
updateSchemaPreview(); // keep preview in sync
419499
}
420500
});
421-
});
501+
}); // closes addEventListener callback, forEach callback, and forEach call
422502

423-
// Attach event listeners to dynamically added parameter inputs
424-
function attachListeners(paramDiv) {
425-
const inputs = paramDiv.querySelectorAll("input, select, textarea");
426-
inputs.forEach((input) => {
427-
input.addEventListener("input", () => {
428-
const mode = document.querySelector(
429-
'input[name="schema_input_mode"]:checked',
430-
).value;
431-
if (mode === "json") {
432-
updateSchemaPreview();
433-
}
434-
});
435-
});
436-
}
437503

438504
// On form submission, update CodeMirror with UI builder schema if needed
439505
// document.getElementById('add-tool-form').addEventListener('submit', (e) => {
@@ -1630,6 +1696,45 @@ async function runToolTest() {
16301696
});
16311697
}
16321698

1699+
/* ---------------------------------------------------------------
1700+
* Utility: copy a JSON string (or any text) to the system clipboard
1701+
* ------------------------------------------------------------- */
1702+
function copyJsonToClipboard(sourceId) {
1703+
// 1. Get the element that holds the JSON (can be a <pre>, <code>, <textarea>, etc.)
1704+
const el = document.getElementById(sourceId);
1705+
if (!el) {
1706+
console.warn(`[copyJsonToClipboard] Source element "${sourceId}" not found.`);
1707+
return;
1708+
}
1709+
1710+
// 2. Extract the text; fall back to textContent if value is undefined
1711+
const text = "value" in el ? el.value : el.textContent;
1712+
1713+
// 3. Copy to clipboard
1714+
navigator.clipboard.writeText(text).then(
1715+
() => {
1716+
console.info("JSON copied to clipboard ✔️");
1717+
// Optional: user feedback
1718+
if (el.dataset.toast !== "off") {
1719+
const toast = document.createElement("div");
1720+
toast.textContent = "Copied!";
1721+
toast.className =
1722+
"fixed bottom-4 right-4 bg-green-600 text-white px-3 py-1 rounded shadow";
1723+
document.body.appendChild(toast);
1724+
setTimeout(() => toast.remove(), 1500);
1725+
}
1726+
},
1727+
(err) => {
1728+
console.error("Clipboard write failed:", err);
1729+
alert("Unable to copy to clipboard - see console for details.");
1730+
}
1731+
);
1732+
}
1733+
1734+
// Make it available to inline onclick handlers
1735+
window.copyJsonToClipboard = copyJsonToClipboard;
1736+
1737+
16331738
// Utility functions to open and close modals
16341739
function openModal(modalId) {
16351740
document.getElementById(modalId).classList.remove("hidden");

mcpgateway/templates/admin.html

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,16 +114,19 @@ <h1 class="text-3xl font-bold text-gray-800">
114114
</a>
115115
<!-- New Version tab (opens separate /version page) -->
116116
<a
117-
href="{{ root_path }}/version"
118-
target="_blank"
119-
class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"
117+
href="#version-info"
118+
id="tab-version-info"
119+
class="tab-link border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"
120120
>
121-
Version
121+
Version and Environment Info
122122
</a>
123123
</nav>
124124
</div>
125125
</div>
126126

127+
<!-- Version Info Panel -->
128+
<div id="version-info-panel" class="tab-panel hidden"></div>
129+
127130
<!-- Catalog Panel -->
128131
<div id="catalog-panel" class="tab-panel">
129132
<div class="flex justify-between items-center mb-4">
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<div class="max-w-2xl mx-auto bg-white rounded-xl shadow-md overflow-hidden p-6">
2+
<div class="md:flex md:space-x-6">
3+
<!-- Icon -->
4+
<div class="flex-shrink-0">
5+
<svg class="h-12 w-12 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none"
6+
viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
7+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
8+
d="M12 8a3 3 0 11-3 3 3 3 0 013-3zm0-6v2m0 16v2m8-10h2M2 12h2m13.364 6.364l1.414 1.414M4.222 4.222l1.414 1.414m12.728 0l-1.414 1.414M6.636 17.364l-1.414 1.414"/>
9+
</svg>
10+
</div>
11+
12+
<!-- Details -->
13+
<div class="flex-1">
14+
<h2 class="uppercase tracking-wide text-sm text-indigo-500 font-semibold">
15+
Version & Environment
16+
</h2>
17+
18+
<!-- App section -->
19+
<div class="mt-2">
20+
<p class="text-lg font-medium text-black">App: {{ payload.app.name }}</p>
21+
<p class="text-gray-500">Git rev: {{ payload.app.git_revision or 'N/A' }}</p>
22+
<p class="text-gray-500">Protocol: {{ payload.app.mcp_protocol_version }}</p>
23+
</div>
24+
25+
<!-- Host & runtime -->
26+
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-x-4 text-gray-700 text-sm">
27+
<div>
28+
<p><span class="font-semibold">Host:</span> {{ payload.host }}</p>
29+
<p><span class="font-semibold">Uptime:</span> {{ payload.uptime_seconds }} s</p>
30+
<p><span class="font-semibold">Boot:</span> {{ payload.system.boot_time }}</p>
31+
</div>
32+
<div>
33+
<p><span class="font-semibold">Python:</span> {{ payload.platform.python }}</p>
34+
<p><span class="font-semibold">FastAPI:</span> {{ payload.platform.fastapi }}</p>
35+
<p><span class="font-semibold">OS:</span> {{ payload.platform.os }}</p>
36+
</div>
37+
</div>
38+
39+
<!-- DB / Redis health -->
40+
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-x-4 text-gray-700 text-sm">
41+
<div>
42+
<p class="font-semibold">Database</p>
43+
<p>Dialect: {{ payload.database.dialect }}</p>
44+
<p>Status:
45+
{% if payload.database.reachable %}
46+
<span class="text-green-600">reachable</span>
47+
{% else %}
48+
<span class="text-red-600">unreachable</span>
49+
{% endif %}
50+
</p>
51+
</div>
52+
<div>
53+
<p class="font-semibold">Redis</p>
54+
<p>
55+
{% if payload.redis.available %}
56+
<span class="text-green-600">available</span>
57+
{% else %}
58+
<span class="text-red-600">not available</span>
59+
{% endif %}
60+
</p>
61+
</div>
62+
</div>
63+
64+
<!-- System snapshot -->
65+
<div class="mt-4 text-gray-700 text-sm">
66+
<p class="font-semibold">System</p>
67+
<p>CPU: {{ payload.system.cpu_count }} × {{ payload.system.cpu_freq_mhz }} MHz</p>
68+
<p>Memory: {{ "{:.1f}".format(payload.system.mem_used_mb) }} / {{ payload.system.mem_total_mb }} MB</p>
69+
<p>Disk: {{ "{:.1f}".format(payload.system.disk_used_gb) }} / {{ "{:.1f}".format(payload.system.disk_total_gb) }} GB</p>
70+
</div>
71+
72+
<!-- Copy-JSON button -->
73+
<button
74+
class="mt-4 px-3 py-1 bg-indigo-600 text-white rounded hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500"
75+
onclick="copyJsonToClipboard('json-data')">
76+
Copy full JSON
77+
</button>
78+
79+
<!-- Collapsible raw JSON (small & scrollable) -->
80+
<pre id="json-data"
81+
class="mt-2 p-2 bg-gray-100 rounded text-xs overflow-auto"
82+
style="max-height: 220px;">{{ payload | tojson(indent=2) }}</pre>
83+
</div>
84+
</div>
85+
</div>
86+
87+
<script>
88+
/**
89+
* Copy the innerText of the element with id sourceId (defaults to "json-data")
90+
* to the clipboard. Intended for inline onclick handlers.
91+
*/
92+
function copyJsonToClipboard(sourceId = 'json-data') {
93+
const el = document.getElementById(sourceId);
94+
if (!el) {
95+
console.warn('[copyJsonToClipboard] sourceId "%s" not found', sourceId);
96+
return;
97+
}
98+
navigator.clipboard.writeText(el.innerText)
99+
.then(() => alert('JSON copied to clipboard!'))
100+
.catch(err => alert('Failed to copy JSON: ' + err));
101+
}
102+
// expose globally for inline usage
103+
window.copyJsonToClipboard = copyJsonToClipboard;
104+
</script>

mcpgateway/version.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,14 +364,16 @@ def _login_html(next_url: str) -> str:
364364
async def version_endpoint(
365365
request: Request,
366366
fmt: Optional[str] = None,
367+
partial: Optional[bool] = False,
367368
_user=Depends(require_auth),
368369
) -> Response:
369370
"""
370-
Serve diagnostics as JSON or HTML (if requested).
371+
Serve diagnostics as JSON, full HTML, or partial HTML (if requested).
371372
372373
Parameters:
373374
request (Request): The incoming HTTP request.
374-
fmt (Optional[str]): Query param 'html' for HTML output.
375+
fmt (Optional[str]): Query param 'html' for full HTML output.
376+
partial (Optional[bool]): Query param to request partial HTML fragment.
375377
376378
Returns:
377379
Response: JSONResponse or HTMLResponse with diagnostics data.
@@ -390,6 +392,11 @@ async def version_endpoint(
390392
redis_version = str(exc)
391393

392394
payload = _build_payload(redis_version, redis_ok)
395+
if partial:
396+
# Return partial HTML fragment for HTMX embedding
397+
from fastapi.templating import Jinja2Templates
398+
templates = Jinja2Templates(directory="mcpgateway/templates")
399+
return templates.TemplateResponse("version_info_partial.html", {"request": request, "payload": payload})
393400
wants_html = fmt == "html" or "text/html" in request.headers.get("accept", "")
394401
if wants_html:
395402
return HTMLResponse(_render_html(payload))

tests/unit/mcpgateway/test_main.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,30 @@ def test_ready_check(self, test_client):
5454
assert response.status_code == 200
5555
assert response.json()["status"] == "ready"
5656

57+
def test_version_partial_html(self, test_client, auth_headers):
58+
"""Test the /version endpoint with partial=true returns HTML fragment."""
59+
response = test_client.get("/version?partial=true", headers=auth_headers)
60+
assert response.status_code == 200
61+
assert "text/html" in response.headers["content-type"]
62+
content = response.text
63+
assert "Version and Environment Info" in content
64+
assert "App:" in content
65+
66+
def test_admin_ui_contains_version_tab(self, test_client, auth_headers):
67+
"""Test the Admin UI contains the Version and Environment Info tab."""
68+
response = test_client.get("/admin", headers=auth_headers)
69+
assert response.status_code == 200
70+
content = response.text
71+
assert 'id="tab-version-info"' in content
72+
assert "Version and Environment Info" in content
73+
74+
def test_version_partial_htmx_load(self, test_client, auth_headers):
75+
"""Test HTMX request to /version?partial=true returns the partial HTML."""
76+
response = test_client.get("/version?partial=true", headers=auth_headers)
77+
assert response.status_code == 200
78+
assert "Version and Environment Info" in response.text
79+
assert "<div" in response.text
80+
5781
def test_root_redirect(self, test_client):
5882
"""Test root path redirects to admin."""
5983
response = test_client.get("/", allow_redirects=False)

0 commit comments

Comments
 (0)