From 43d627ab9dcb2e838b93edbe05a01fc63d6925e9 Mon Sep 17 00:00:00 2001 From: LD-Reborn Date: Sat, 18 Jul 2026 21:52:12 +0200 Subject: [PATCH] fix(server): fixes admin page gets full DOM reload every 15 seconds --- docs/licenses/LICENSE.morphdom | 21 + src/ReverseLlama.Server/wwwroot/admin/app.js | 90 +- .../wwwroot/admin/index.html | 1 + .../wwwroot/admin/morphdom-umd.js | 783 ++++++++++++++++++ 4 files changed, 863 insertions(+), 32 deletions(-) create mode 100644 docs/licenses/LICENSE.morphdom create mode 100644 src/ReverseLlama.Server/wwwroot/admin/morphdom-umd.js diff --git a/docs/licenses/LICENSE.morphdom b/docs/licenses/LICENSE.morphdom new file mode 100644 index 0000000..d4d3516 --- /dev/null +++ b/docs/licenses/LICENSE.morphdom @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Patrick Steele-Idem (psteeleidem.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/src/ReverseLlama.Server/wwwroot/admin/app.js b/src/ReverseLlama.Server/wwwroot/admin/app.js index 8364f2a..6d575bc 100644 --- a/src/ReverseLlama.Server/wwwroot/admin/app.js +++ b/src/ReverseLlama.Server/wwwroot/admin/app.js @@ -16,6 +16,26 @@ const sidebarMeta = document.getElementById("sidebarMeta"); document.getElementById("refreshButton").addEventListener("click", () => refresh(true)); window.addEventListener("hashchange", () => renderRoute()); +const morphdomOptions = { + childrenOnly: true, + onBeforeElUpdated(fromEl, toEl) { + if (fromEl.isEqualNode(toEl)) return false; + return true; + } +}; + +function patchContent(html) { + const temp = document.createElement("section"); + temp.innerHTML = html; + morphdom(content, temp, morphdomOptions); +} + +function patchSidebar(html) { + const temp = document.createElement("div"); + temp.innerHTML = html; + morphdom(sidebarMeta, temp, morphdomOptions); +} + content.addEventListener("click", async (event) => { const button = event.target.closest("button[data-action]"); if (!button) { @@ -263,7 +283,7 @@ async function refresh(showNotice = false, render = true) { state.summary = await api("/summary"); updateShell(); if (render) { - await renderRoute(); + await renderRoute(false); } if (showNotice) { @@ -271,7 +291,7 @@ async function refresh(showNotice = false, render = true) { } } catch (error) { setNotice(error.message, true); - content.innerHTML = `
${escapeHtml(error.message)}
`; + patchContent(`
${escapeHtml(error.message)}
`); } finally { state.loading = false; } @@ -304,7 +324,7 @@ async function api(path, options = {}) { return body; } -async function renderRoute() { +async function renderRoute(showLoading = true) { const hash = (window.location.hash || "#clients").slice(1); const parts = hash.split("/"); const view = parts[0]; @@ -316,13 +336,13 @@ async function renderRoute() { if (view === "models" && encodedParam) { const model = decodeURIComponent(encodedParam); - await loadModelDetail(model); + await loadModelDetail(model, undefined, showLoading); return; } if (view === "groups" && encodedParam) { const groupId = decodeURIComponent(encodedParam); - await loadGroupDetail(groupId); + await loadGroupDetail(groupId, showLoading); return; } @@ -345,7 +365,7 @@ async function renderRoute() { } if (view === "usage") { - await loadUsage(); + await loadUsage(showLoading); return; } @@ -358,13 +378,13 @@ function updateShell() { return; } - sidebarMeta.innerHTML = ` + patchSidebar(`
${escapeHtml(summary.user?.name || "Signed in")}
${summary.clients.length} clients
${summary.models.length} models
${(summary.groups || []).length} groups
${formatDate(summary.generatedAtUtc)}
- `; + `); } function renderClients() { @@ -372,7 +392,7 @@ function renderClients() { pageTitle.textContent = "Clients"; pageSubtitle.textContent = "Connected tunnel clients, request counts, and forwarding controls."; - content.innerHTML = ` + patchContent(`

Clients

@@ -382,7 +402,7 @@ function renderClients() { ${clients.length ? clientsTable(clients) : emptyState("No clients have connected yet.")}
- `; + `); } function clientsTable(clients) { @@ -449,7 +469,7 @@ function renderModels() { pageTitle.textContent = "Models"; pageSubtitle.textContent = "Listed and active models, recent request volume, and model operations."; - content.innerHTML = ` + patchContent(`

Run model action

@@ -467,7 +487,7 @@ function renderModels() { ${models.length ? modelsTable(models) : emptyState("No models have been reported yet.")}
- `; + `); } function modelActionForm(clients, selectedModel = "", selectedClient = "") { @@ -537,10 +557,12 @@ function modelsTable(models) { `; } -async function loadModelDetail(model, clientId) { +async function loadModelDetail(model, clientId, showLoading = true) { pageTitle.textContent = "Model Detail"; pageSubtitle.textContent = model; - content.innerHTML = `
Loading model detail...
`; + if (showLoading) { + patchContent(`
Loading model detail...
`); + } const query = new URLSearchParams({ model }); if (clientId) { @@ -562,7 +584,7 @@ function renderModelDetail() { pageTitle.textContent = "Model Detail"; pageSubtitle.textContent = model; - content.innerHTML = ` + patchContent(` @@ -618,7 +640,7 @@ function renderModelDetail() { ${detail.show ? `
${escapeHtml(formatJson(showBody))}
` : emptyState("No connected client was available for details.")} - `; + `); } async function runModelCommand(clientId, model, action) { @@ -664,7 +686,7 @@ function renderApiKeys() { pageTitle.textContent = "API Keys"; pageSubtitle.textContent = "Keys accepted by the proxy token header, bearer auth, query token, and token path."; - content.innerHTML = ` + patchContent(` ${state.newKey ? newKeyPanel(state.newKey) : ""}
@@ -689,7 +711,7 @@ function renderApiKeys() { ${keys.length ? apiKeysTable(keys) : emptyState("No API keys have been created.")}
- `; + `); } function newKeyPanel(key) { @@ -739,7 +761,7 @@ function renderGroups() { pageTitle.textContent = "Groups"; pageSubtitle.textContent = "Manage access groups that control which clients and models API keys can reach."; - content.innerHTML = ` + patchContent(`

Create group

@@ -763,7 +785,7 @@ function renderGroups() { ${groups.length ? groupsTable(groups) : emptyState("No groups have been created.")}
- `; + `); } function groupsTable(groups) { @@ -796,10 +818,12 @@ function groupsTable(groups) { `; } -async function loadGroupDetail(groupId) { +async function loadGroupDetail(groupId, showLoading = true) { pageTitle.textContent = "Group Detail"; pageSubtitle.textContent = groupId; - content.innerHTML = `
Loading group...
`; + if (showLoading) { + patchContent(`
Loading group...
`); + } try { const [group, clients, apiKeyGroups, billing, rules, payments, balance] = await Promise.all([ @@ -815,7 +839,7 @@ async function loadGroupDetail(groupId) { state.groupDetail = { group, clients, apiKeyGroups, billing, rules, payments, balance }; renderGroupDetail(); } catch (error) { - content.innerHTML = `
${escapeHtml(error.message)}
`; + patchContent(`
${escapeHtml(error.message)}
`); } } @@ -831,7 +855,7 @@ function renderGroupDetail() { pageTitle.textContent = "Group Detail"; pageSubtitle.textContent = group.name; - content.innerHTML = ` + patchContent(` @@ -979,7 +1003,7 @@ function renderGroupDetail() { - `; + `); } function groupClientsTable(clients, groupId) { @@ -1143,10 +1167,12 @@ function formatCurrency(value, currency) { return formatted; } -async function loadUsage() { +async function loadUsage(showLoading = true) { pageTitle.textContent = "Usage"; pageSubtitle.textContent = "Token usage and revenue statistics."; - content.innerHTML = `
Loading usage data...
`; + if (showLoading) { + patchContent(`
Loading usage data...
`); + } try { const [usage, revenue] = await Promise.all([ @@ -1157,7 +1183,7 @@ async function loadUsage() { state.usageData = { usage, revenue }; renderUsage(); } catch (error) { - content.innerHTML = `
${escapeHtml(error.message)}
`; + patchContent(`
${escapeHtml(error.message)}
`); } } @@ -1179,7 +1205,7 @@ function renderUsage() { revenueMap[r.clientId] = r; } - content.innerHTML = ` + patchContent(`

Tokens by model

@@ -1216,7 +1242,7 @@ function renderUsage() { ${byGroup.length ? tokenStatsGroupTable(byGroup) : emptyState("No token data yet.")}
- `; + `); } function tokenStatsModelTable(stats) { @@ -1362,11 +1388,11 @@ async function refreshAfterModelCommand(model, clientId) { const hash = window.location.hash || ""; if (hash.startsWith(`#models/${encodeURIComponent(model)}`)) { - await loadModelDetail(model, clientId); + await loadModelDetail(model, clientId, false); return; } - await renderRoute(); + await renderRoute(false); } function modelActionResultDetail(result) { diff --git a/src/ReverseLlama.Server/wwwroot/admin/index.html b/src/ReverseLlama.Server/wwwroot/admin/index.html index e6ddab3..b8ac06b 100644 --- a/src/ReverseLlama.Server/wwwroot/admin/index.html +++ b/src/ReverseLlama.Server/wwwroot/admin/index.html @@ -45,6 +45,7 @@ + diff --git a/src/ReverseLlama.Server/wwwroot/admin/morphdom-umd.js b/src/ReverseLlama.Server/wwwroot/admin/morphdom-umd.js new file mode 100644 index 0000000..af89a5f --- /dev/null +++ b/src/ReverseLlama.Server/wwwroot/admin/morphdom-umd.js @@ -0,0 +1,783 @@ +/*! + * morphdom + * Copyright (c) Patrick Steele-Idem + * Licensed under the MIT License. + * See /docs/licenses/LICENSE.morphdom + */ +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + (global = global || self, global.morphdom = factory()); +}(this, function () { 'use strict'; + + var DOCUMENT_FRAGMENT_NODE = 11; + + function morphAttrs(fromNode, toNode) { + var toNodeAttrs = toNode.attributes; + var attr; + var attrName; + var attrNamespaceURI; + var attrValue; + var fromValue; + + // document-fragments dont have attributes so lets not do anything + if (toNode.nodeType === DOCUMENT_FRAGMENT_NODE || fromNode.nodeType === DOCUMENT_FRAGMENT_NODE) { + return; + } + + // update attributes on original DOM element + for (var i = toNodeAttrs.length - 1; i >= 0; i--) { + attr = toNodeAttrs[i]; + attrName = attr.name; + attrNamespaceURI = attr.namespaceURI; + attrValue = attr.value; + + if (attrNamespaceURI) { + attrName = attr.localName || attrName; + fromValue = fromNode.getAttributeNS(attrNamespaceURI, attrName); + + if (fromValue !== attrValue) { + if (attr.prefix === 'xmlns'){ + attrName = attr.name; // It's not allowed to set an attribute with the XMLNS namespace without specifying the `xmlns` prefix + } + fromNode.setAttributeNS(attrNamespaceURI, attrName, attrValue); + } + } else { + fromValue = fromNode.getAttribute(attrName); + + if (fromValue !== attrValue) { + fromNode.setAttribute(attrName, attrValue); + } + } + } + + // Remove any extra attributes found on the original DOM element that + // weren't found on the target element. + var fromNodeAttrs = fromNode.attributes; + + for (var d = fromNodeAttrs.length - 1; d >= 0; d--) { + attr = fromNodeAttrs[d]; + attrName = attr.name; + attrNamespaceURI = attr.namespaceURI; + + if (attrNamespaceURI) { + attrName = attr.localName || attrName; + + if (!toNode.hasAttributeNS(attrNamespaceURI, attrName)) { + fromNode.removeAttributeNS(attrNamespaceURI, attrName); + } + } else { + if (!toNode.hasAttribute(attrName)) { + fromNode.removeAttribute(attrName); + } + } + } + } + + var range; // Create a range object for efficently rendering strings to elements. + var NS_XHTML = 'http://www.w3.org/1999/xhtml'; + + var doc = typeof document === 'undefined' ? undefined : document; + var HAS_TEMPLATE_SUPPORT = !!doc && 'content' in doc.createElement('template'); + var HAS_RANGE_SUPPORT = !!doc && doc.createRange && 'createContextualFragment' in doc.createRange(); + + function createFragmentFromTemplate(str) { + var template = doc.createElement('template'); + template.innerHTML = str; + return template.content.childNodes[0]; + } + + function createFragmentFromRange(str) { + if (!range) { + range = doc.createRange(); + range.selectNode(doc.body); + } + + var fragment = range.createContextualFragment(str); + return fragment.childNodes[0]; + } + + function createFragmentFromWrap(str) { + var fragment = doc.createElement('body'); + fragment.innerHTML = str; + return fragment.childNodes[0]; + } + + /** + * This is about the same + * var html = new DOMParser().parseFromString(str, 'text/html'); + * return html.body.firstChild; + * + * @method toElement + * @param {String} str + */ + function toElement(str) { + str = str.trim(); + if (HAS_TEMPLATE_SUPPORT) { + // avoid restrictions on content for things like `Hi` which + // createContextualFragment doesn't support + //