Skip to content

Add a new tab for version and environment info #76

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 11, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 125 additions & 18 deletions mcpgateway/static/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,61 @@ document.addEventListener("DOMContentLoaded", function () {
document.getElementById("tab-metrics").addEventListener("click", () => {
showTab("metrics");
});
document.getElementById("tab-version-info").addEventListener("click", () => {
showTab("version-info");
});

/* ------------------------------------------------------------------
* Pre-load the "Version & Environment Info" partial once per page
* ------------------------------------------------------------------ */
/* Pre-load version-info once */
document.addEventListener("DOMContentLoaded", () => {
const panel = document.getElementById("version-info-panel");
if (!panel || panel.innerHTML.trim() !== "") return; // already loaded

fetch(`${window.ROOT_PATH}/version?partial=true`)
.then((response) => {
if (!response.ok) throw new Error("Network response was not ok");
return response.text();
})
.then((html) => {
panel.innerHTML = html;

// If the page was opened at #version-info, show that tab now
if (window.location.hash === "#version-info") {
showTab("version-info");
}
})
.catch((error) => {
console.error("Failed to preload version info:", error);
panel.innerHTML =
"<p class='text-red-600'>Failed to load version info.</p>";
});
});

/* ------------------------------------------------------------------
* HTMX debug hooks
* ------------------------------------------------------------------ */
document.body.addEventListener("htmx:afterSwap", (event) => {
if (event.detail.target.id === "version-info-panel") {
console.log("HTMX: Content swapped into version-info-panel");
}
});


// HTMX event listeners for debugging
document.body.addEventListener("htmx:beforeRequest", (event) => {
if (event.detail.elt.id === "tab-version-info") {
console.log("HTMX: Sending request for version info partial");
}
});

document.body.addEventListener("htmx:afterSwap", (event) => {
if (event.detail.target.id === "version-info-panel") {
console.log("HTMX: Content swapped into version-info-panel");
}
});

// Authentication toggle
document.getElementById("auth-type").addEventListener("change", function () {
const basicFields = document.getElementById("auth-basic-fields");
Expand Down Expand Up @@ -96,7 +151,9 @@ document.addEventListener("DOMContentLoaded", function () {
.getElementById("add-gateway-form")
.addEventListener("submit", (e) => {
e.preventDefault();
const form = e.target;
const formerror = e.t
console.error("Error:", error);
arget;
const formData = new FormData(form);
fetch(`${window.ROOT_PATH}/admin/gateways`, {
method: "POST",
Expand All @@ -110,6 +167,7 @@ document.addEventListener("DOMContentLoaded", function () {
status.classList.add("error-status");
} else {
location.reload();
console.log(response);
}
})
.catch((error) => {
Expand Down Expand Up @@ -266,6 +324,7 @@ document.addEventListener("DOMContentLoaded", function () {
REST: ["GET", "POST", "PUT", "DELETE"],
};


// Optionally pass in a pre-selected method
function updateEditToolRequestTypes(selectedMethod = null) {
const selectedType = editToolTypeSelect.value;
Expand Down Expand Up @@ -334,6 +393,27 @@ function showTab(tabName) {
if (tabName === "metrics") {
loadAggregatedMetrics();
}

if (tabName === "version-info") {
const panel = document.getElementById("version-info-panel");
if (panel && panel.innerHTML.trim() === "") {
const url = `${window.ROOT_PATH}/version?partial=true`;
fetch(url)
.then((response) => {
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.text();
})
.then((html) => {
panel.innerHTML = html;
})
.catch((error) => {
console.error("Failed to load version info:", error);
panel.innerHTML = "<p class='text-red-600'>Failed to load version info.</p>";
});
}
}
}

// handle auth type selection
Expand Down Expand Up @@ -406,7 +486,9 @@ function updateSchemaPreview() {
}
}

// Refresh CodeMirror every time Direct JSON Input is selected
/* ---------------------------------------------------------------
* Switch between "UI-builder" and "JSON input" modes
* ------------------------------------------------------------- */
Array.from(schemaModeRadios).forEach((radio) => {
radio.addEventListener("change", () => {
if (radio.value === "ui" && radio.checked) {
Expand All @@ -415,25 +497,11 @@ Array.from(schemaModeRadios).forEach((radio) => {
} else if (radio.value === "json" && radio.checked) {
uiBuilderDiv.style.display = "none";
jsonInputContainer.style.display = "block";
updateSchemaPreview();
updateSchemaPreview(); // keep preview in sync
}
});
});
}); // closes addEventListener callback, forEach callback, and forEach call

// Attach event listeners to dynamically added parameter inputs
function attachListeners(paramDiv) {
const inputs = paramDiv.querySelectorAll("input, select, textarea");
inputs.forEach((input) => {
input.addEventListener("input", () => {
const mode = document.querySelector(
'input[name="schema_input_mode"]:checked',
).value;
if (mode === "json") {
updateSchemaPreview();
}
});
});
}

// On form submission, update CodeMirror with UI builder schema if needed
// document.getElementById('add-tool-form').addEventListener('submit', (e) => {
Expand Down Expand Up @@ -1631,6 +1699,45 @@ async function runToolTest() {
});
}

/* ---------------------------------------------------------------
* Utility: copy a JSON string (or any text) to the system clipboard
* ------------------------------------------------------------- */
function copyJsonToClipboard(sourceId) {
// 1. Get the element that holds the JSON (can be a <pre>, <code>, <textarea>, etc.)
const el = document.getElementById(sourceId);
if (!el) {
console.warn(`[copyJsonToClipboard] Source element "${sourceId}" not found.`);
return;
}

// 2. Extract the text; fall back to textContent if value is undefined
const text = "value" in el ? el.value : el.textContent;

// 3. Copy to clipboard
navigator.clipboard.writeText(text).then(
() => {
console.info("JSON copied to clipboard ✔️");
// Optional: user feedback
if (el.dataset.toast !== "off") {
const toast = document.createElement("div");
toast.textContent = "Copied!";
toast.className =
"fixed bottom-4 right-4 bg-green-600 text-white px-3 py-1 rounded shadow";
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 1500);
}
},
(err) => {
console.error("Clipboard write failed:", err);
alert("Unable to copy to clipboard - see console for details.");
}
);
}

// Make it available to inline onclick handlers
window.copyJsonToClipboard = copyJsonToClipboard;


// Utility functions to open and close modals
function openModal(modalId) {
document.getElementById(modalId).classList.remove("hidden");
Expand Down
11 changes: 7 additions & 4 deletions mcpgateway/templates/admin.html
Original file line number Diff line number Diff line change
Expand Up @@ -114,16 +114,19 @@ <h1 class="text-3xl font-bold text-gray-800">
</a>
<!-- New Version tab (opens separate /version page) -->
<a
href="{{ root_path }}/version"
target="_blank"
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"
href="#version-info"
id="tab-version-info"
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"
>
Version
Version and Environment Info
</a>
</nav>
</div>
</div>

<!-- Version Info Panel -->
<div id="version-info-panel" class="tab-panel hidden"></div>

<!-- Catalog Panel -->
<div id="catalog-panel" class="tab-panel">
<div class="flex justify-between items-center mb-4">
Expand Down
104 changes: 104 additions & 0 deletions mcpgateway/templates/version_info_partial.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<div class="max-w-2xl mx-auto bg-white rounded-xl shadow-md overflow-hidden p-6">
<div class="md:flex md:space-x-6">
<!-- Icon -->
<div class="flex-shrink-0">
<svg class="h-12 w-12 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
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"/>
</svg>
</div>

<!-- Details -->
<div class="flex-1">
<h2 class="uppercase tracking-wide text-sm text-indigo-500 font-semibold">
Version & Environment
</h2>

<!-- App section -->
<div class="mt-2">
<p class="text-lg font-medium text-black">App: {{ payload.app.name }}</p>
<p class="text-gray-500">Git rev: {{ payload.app.git_revision or 'N/A' }}</p>
<p class="text-gray-500">Protocol: {{ payload.app.mcp_protocol_version }}</p>
</div>

<!-- Host & runtime -->
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-x-4 text-gray-700 text-sm">
<div>
<p><span class="font-semibold">Host:</span> {{ payload.host }}</p>
<p><span class="font-semibold">Uptime:</span> {{ payload.uptime_seconds }} s</p>
<p><span class="font-semibold">Boot:</span> {{ payload.system.boot_time }}</p>
</div>
<div>
<p><span class="font-semibold">Python:</span> {{ payload.platform.python }}</p>
<p><span class="font-semibold">FastAPI:</span> {{ payload.platform.fastapi }}</p>
<p><span class="font-semibold">OS:</span> {{ payload.platform.os }}</p>
</div>
</div>

<!-- DB / Redis health -->
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-x-4 text-gray-700 text-sm">
<div>
<p class="font-semibold">Database</p>
<p>Dialect: {{ payload.database.dialect }}</p>
<p>Status:
{% if payload.database.reachable %}
<span class="text-green-600">reachable</span>
{% else %}
<span class="text-red-600">unreachable</span>
{% endif %}
</p>
</div>
<div>
<p class="font-semibold">Redis</p>
<p>
{% if payload.redis.available %}
<span class="text-green-600">available</span>
{% else %}
<span class="text-red-600">not available</span>
{% endif %}
</p>
</div>
</div>

<!-- System snapshot -->
<div class="mt-4 text-gray-700 text-sm">
<p class="font-semibold">System</p>
<p>CPU: {{ payload.system.cpu_count }} × {{ payload.system.cpu_freq_mhz }} MHz</p>
<p>Memory: {{ "{:.1f}".format(payload.system.mem_used_mb) }} / {{ payload.system.mem_total_mb }} MB</p>
<p>Disk: {{ "{:.1f}".format(payload.system.disk_used_gb) }} / {{ "{:.1f}".format(payload.system.disk_total_gb) }} GB</p>
</div>

<!-- Copy-JSON button -->
<button
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"
onclick="copyJsonToClipboard('json-data')">
Copy full JSON
</button>

<!-- Collapsible raw JSON (small & scrollable) -->
<pre id="json-data"
class="mt-2 p-2 bg-gray-100 rounded text-xs overflow-auto"
style="max-height: 220px;">{{ payload | tojson(indent=2) }}</pre>
</div>
</div>
</div>

<script>
/**
* Copy the innerText of the element with id sourceId (defaults to "json-data")
* to the clipboard. Intended for inline onclick handlers.
*/
function copyJsonToClipboard(sourceId = 'json-data') {
const el = document.getElementById(sourceId);
if (!el) {
console.warn('[copyJsonToClipboard] sourceId "%s" not found', sourceId);
return;
}
navigator.clipboard.writeText(el.innerText)
.then(() => alert('JSON copied to clipboard!'))
.catch(err => alert('Failed to copy JSON: ' + err));
}
// expose globally for inline usage
window.copyJsonToClipboard = copyJsonToClipboard;
</script>
11 changes: 9 additions & 2 deletions mcpgateway/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,14 +364,16 @@ def _login_html(next_url: str) -> str:
async def version_endpoint(
request: Request,
fmt: Optional[str] = None,
partial: Optional[bool] = False,
_user=Depends(require_auth),
) -> Response:
"""
Serve diagnostics as JSON or HTML (if requested).
Serve diagnostics as JSON, full HTML, or partial HTML (if requested).

Parameters:
request (Request): The incoming HTTP request.
fmt (Optional[str]): Query param 'html' for HTML output.
fmt (Optional[str]): Query param 'html' for full HTML output.
partial (Optional[bool]): Query param to request partial HTML fragment.

Returns:
Response: JSONResponse or HTMLResponse with diagnostics data.
Expand All @@ -390,6 +392,11 @@ async def version_endpoint(
redis_version = str(exc)

payload = _build_payload(redis_version, redis_ok)
if partial:
# Return partial HTML fragment for HTMX embedding
from fastapi.templating import Jinja2Templates
templates = Jinja2Templates(directory="mcpgateway/templates")
return templates.TemplateResponse("version_info_partial.html", {"request": request, "payload": payload})
wants_html = fmt == "html" or "text/html" in request.headers.get("accept", "")
if wants_html:
return HTMLResponse(_render_html(payload))
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/mcpgateway/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,30 @@ def test_ready_check(self, test_client):
assert response.status_code == 200
assert response.json()["status"] == "ready"

def test_version_partial_html(self, test_client, auth_headers):
"""Test the /version endpoint with partial=true returns HTML fragment."""
response = test_client.get("/version?partial=true", headers=auth_headers)
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
content = response.text
assert "Version and Environment Info" in content
assert "App:" in content

def test_admin_ui_contains_version_tab(self, test_client, auth_headers):
"""Test the Admin UI contains the Version and Environment Info tab."""
response = test_client.get("/admin", headers=auth_headers)
assert response.status_code == 200
content = response.text
assert 'id="tab-version-info"' in content
assert "Version and Environment Info" in content

def test_version_partial_htmx_load(self, test_client, auth_headers):
"""Test HTMX request to /version?partial=true returns the partial HTML."""
response = test_client.get("/version?partial=true", headers=auth_headers)
assert response.status_code == 200
assert "Version and Environment Info" in response.text
assert "<div" in response.text

def test_root_redirect(self, test_client):
"""Test root path redirects to admin."""
response = test_client.get("/", allow_redirects=False)
Expand Down
Loading