From 41cdcd918889633f6d56620c21332accda8aad92 Mon Sep 17 00:00:00 2001 From: jakeichikawasalesforce Date: Tue, 15 Sep 2026 09:57:59 -0700 Subject: [PATCH 1/7] Redesign TabPy server landing page --- tabpy/tabpy_server/static/index.html | 975 +++++++++++++++++++- tests/integration/test_url.py | 21 +- tests/unit/server_tests/test_static_page.py | 82 ++ 3 files changed, 1022 insertions(+), 56 deletions(-) create mode 100644 tests/unit/server_tests/test_static_page.py diff --git a/tabpy/tabpy_server/static/index.html b/tabpy/tabpy_server/static/index.html index f47e6a06..762e8495 100644 --- a/tabpy/tabpy_server/static/index.html +++ b/tabpy/tabpy_server/static/index.html @@ -1,71 +1,946 @@ - + + + + + + + + TabPy Server + + +.skip-link:focus { transform: translateY(0); } +.page-width, .header-inner { width: min(1200px, calc(100% - 48px)); margin-inline: auto; } - - + const scriptSource = document.currentScript && document.currentScript.src; + const resourceBase = scriptSource ? new URL(".", scriptSource) : new URL("./", window.location.href); + const themeMedia = window.matchMedia("(prefers-color-scheme: dark)"); + const sectionState = { + info: { status: "loading", data: null, error: null, view: "formatted" }, + models: { status: "loading", data: null, error: null, view: "formatted", query: "" } + }; + let toastTimer; + + function createElement(tagName, className, text) { + const node = document.createElement(tagName); + if (className) node.className = className; + if (text !== undefined) node.textContent = String(text); + return node; + } + + function createSvgSearchIcon() { + const namespace = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(namespace, "svg"); + const circle = document.createElementNS(namespace, "circle"); + const path = document.createElementNS(namespace, "path"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("width", "18"); + svg.setAttribute("height", "18"); + svg.setAttribute("aria-hidden", "true"); + circle.setAttribute("cx", "11"); + circle.setAttribute("cy", "11"); + circle.setAttribute("r", "6.5"); + circle.setAttribute("fill", "none"); + circle.setAttribute("stroke", "currentColor"); + circle.setAttribute("stroke-width", "1.8"); + path.setAttribute("d", "m16 16 4.2 4.2"); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", "currentColor"); + path.setAttribute("stroke-width", "1.8"); + path.setAttribute("stroke-linecap", "round"); + svg.append(circle, path); + return svg; + } + + function getStoredTheme() { + try { + const value = window.localStorage.getItem("tabpy-theme"); + return value === "light" || value === "dark" ? value : null; + } catch (_error) { + return null; + } + } + + function currentTheme() { + return document.documentElement.dataset.theme || (themeMedia.matches ? "dark" : "light"); + } + + function updateThemeControl() { + const button = document.getElementById("theme-toggle"); + const dark = currentTheme() === "dark"; + button.setAttribute("aria-pressed", String(dark)); + button.setAttribute("aria-label", dark ? "Switch to light theme" : "Switch to dark theme"); + } + + function initializeTheme() { + const stored = getStoredTheme(); + if (stored) document.documentElement.dataset.theme = stored; + updateThemeControl(); + document.getElementById("theme-toggle").addEventListener("click", function () { + const next = currentTheme() === "dark" ? "light" : "dark"; + document.documentElement.dataset.theme = next; + try { + window.localStorage.setItem("tabpy-theme", next); + } catch (_error) { + // The selected theme still applies for this page view when storage is unavailable. + } + updateThemeControl(); + }); + themeMedia.addEventListener("change", function () { + if (!getStoredTheme()) updateThemeControl(); + }); + } + + function formatLabel(key) { + return String(key) + .replace(/[_-]+/g, " ") + .replace(/\b\w/g, function (character) { return character.toUpperCase(); }); + } + + function isObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); + } + + function isTimestampKey(key) { + return /(^|_)time$/i.test(String(key)); + } + + function formatTimestamp(value) { + if (typeof value !== "number" || !Number.isFinite(value)) return null; + const milliseconds = Math.abs(value) < 100000000000 ? value * 1000 : value; + const date = new Date(milliseconds); + if (Number.isNaN(date.getTime())) return null; + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "medium" + }).format(date); + } + + function renderBoolean(value) { + const node = createElement("span", "boolean-status" + (value ? " is-enabled" : "")); + const visual = createElement("span", "status-switch"); + visual.setAttribute("aria-hidden", "true"); + node.append(visual, document.createTextNode(value ? "Enabled" : "Disabled")); + return node; + } + + function renderValue(value, key) { + const wrapper = createElement("div", "recursive-value"); + if (value === null) { + wrapper.append(createElement("span", "empty-value", "null")); + return wrapper; + } + if (typeof value === "boolean") { + wrapper.append(renderBoolean(value)); + return wrapper; + } + if (typeof value === "number" && isTimestampKey(key)) { + const formatted = formatTimestamp(value); + if (formatted) { + wrapper.append(createElement("span", "field-value", formatted)); + wrapper.append(createElement("span", "field-raw", "Raw: " + String(value))); + return wrapper; + } + } + if (Array.isArray(value)) { + const array = createElement("div", "recursive-array"); + if (value.length === 0) { + array.append(createElement("span", "empty-value", "Empty array []")); + } else { + const complex = value.some(function (item) { return isObject(item) || Array.isArray(item); }); + if (complex) array.classList.add("is-complex"); + value.forEach(function (item, index) { + const itemNode = createElement("div", "array-item"); + if (complex) itemNode.append(createElement("span", "field-raw", "Item " + (index + 1))); + itemNode.append(renderValue(item, index)); + array.append(itemNode); + }); + } + wrapper.append(array); + return wrapper; + } + if (isObject(value)) { + wrapper.append(renderObject(value)); + return wrapper; + } + if (String(key).toLowerCase() === "docstring") { + const pre = createElement("pre", "", value === "" ? "Empty string \"\"" : value); + wrapper.append(pre); + return wrapper; + } + if (value === "") { + wrapper.append(createElement("span", "empty-value", "Empty string \"\"")); + return wrapper; + } + wrapper.textContent = String(value); + return wrapper; + } + + function renderObject(object, keys) { + const container = createElement("div", "recursive-object"); + const entries = keys ? keys.map(function (key) { return [key, object[key]]; }) : Object.entries(object); + if (entries.length === 0) { + container.append(createElement("div", "recursive-row empty-value", "Empty object {}")); + return container; + } + entries.forEach(function (entry) { + const row = createElement("div", "recursive-row"); + const key = createElement("div", "recursive-key", entry[0]); + row.append(key, renderValue(entry[1], entry[0])); + container.append(row); + }); + return container; + } + + function getAuthentication(info) { + const versions = isObject(info.versions) ? Object.values(info.versions) : []; + const configurations = versions + .map(function (version) { return isObject(version) && isObject(version.features) ? version.features.authentication : null; }) + .filter(isObject); + const methods = new Set(); + configurations.forEach(function (configuration) { + if (isObject(configuration.methods)) { + Object.keys(configuration.methods).forEach(function (method) { methods.add(method); }); + } + }); + return { + advertised: configurations.length > 0, + required: configurations.some(function (configuration) { return configuration.required === true; }), + methods: Array.from(methods).sort() + }; + } + function friendlyAuthMethod(method) { + if (method === "basic-auth") return "Basic auth"; + if (method === "oauth-jwt") return "OAuth / JWT"; + return formatLabel(method); + } + + function updateHero(info, error) { + const name = document.getElementById("server-name"); + const description = document.getElementById("server-description"); + const status = document.getElementById("hero-status"); + status.replaceChildren(); + if (error) { + name.textContent = "TabPy Server"; + description.textContent = "The landing page is available, but server details could not be loaded."; + status.append(createElement("span", "status-pill status-pill-danger", "Server details unavailable")); + return; + } + + name.textContent = info.name || "TabPy Server"; + description.textContent = info.description || "Live server details and deployed Python models, all in one place."; + if (info.server_version !== undefined) { + status.append(createElement("span", "status-pill status-pill-accent", "Version " + String(info.server_version))); + } + const authentication = getAuthentication(info); + const authText = authentication.advertised + ? (authentication.required ? "Authentication required" : "Authentication available") + : "Authentication not advertised"; + status.append(createElement("span", authentication.required ? "status-pill status-pill-success" : "status-pill", authText)); + authentication.methods.forEach(function (method) { + status.append(createElement("span", "status-pill", friendlyAuthMethod(method))); + }); + } + + function createMetadataItem(label, value, key) { + const item = createElement("div", "metadata-item"); + item.append(createElement("span", "field-label", label)); + item.append(renderValue(value, key)); + return item; + } + + function createPanel(title, object) { + const panel = createElement("div", "detail-panel"); + const header = createElement("div", "detail-panel-header"); + header.append(createElement("h3", "", title)); + panel.append(header, renderObject(object)); + return panel; + } + + function renderInfoFormatted(info) { + const fragment = document.createDocumentFragment(); + const overview = createElement("div", "info-overview"); + const identity = createElement("div", "identity-card"); + identity.append(createElement("span", "mini-label", "Server identity")); + identity.append(createElement("h3", "", info.name || "Unnamed TabPy server")); + identity.append(createElement("p", "", info.description || "No server description provided.")); + + const authentication = getAuthentication(info); + const auth = createElement("div", "auth-card"); + auth.append(createElement("span", "mini-label", "Authentication")); + const authState = createElement("div", "auth-state"); + if (!authentication.advertised) authState.classList.add("is-muted"); + authState.append(createElement("span", "auth-state-dot")); + authState.append(document.createTextNode(authentication.advertised + ? (authentication.required ? "Required" : "Available") + : "Not advertised")); + auth.append(authState); + const methods = createElement("div", "tag-row"); + if (authentication.methods.length) { + authentication.methods.forEach(function (method) { methods.append(createElement("span", "tag tag-success", friendlyAuthMethod(method))); }); + } else { + methods.append(createElement("span", "tag", "No methods listed")); + } + auth.append(methods); + overview.append(identity, auth); + fragment.append(overview); + + const metadata = createElement("div", "metadata-grid"); + [ + ["Server version", info.server_version, "server_version"], + ["Created", info.creation_time, "creation_time"], + ["State path", info.state_path, "state_path"] + ].forEach(function (field) { metadata.append(createMetadataItem(field[0], field[1], field[2])); }); + fragment.append(metadata); + + if (Object.prototype.hasOwnProperty.call(info, "versions")) { + fragment.append(createPanel("API versions and features", { versions: info.versions })); + } + const promoted = new Set(["name", "description", "server_version", "creation_time", "state_path", "versions"]); + const additional = Object.fromEntries(Object.entries(info).filter(function (entry) { return !promoted.has(entry[0]); })); + if (Object.keys(additional).length) fragment.append(createPanel("Additional server fields", additional)); + return fragment; + } + + function createJsonView(data, route, failedResponse) { + const shell = createElement("div", "json-shell"); + const toolbar = createElement("div", "json-toolbar"); + const routeLabel = "GET " + route + (failedResponse && failedResponse.status ? " ยท HTTP " + failedResponse.status : ""); + toolbar.append(createElement("code", "", routeLabel)); + let displayedResponse; + let copiedResponse; + if (failedResponse) { + copiedResponse = failedResponse.rawText || ""; + if (failedResponse.isJson) { + displayedResponse = JSON.stringify(failedResponse.data, null, 2); + } else { + displayedResponse = failedResponse.rawText || "No response body was received."; + } + } else { + displayedResponse = JSON.stringify(data, null, 2); + copiedResponse = displayedResponse; + } + const copy = createElement("button", "copy-button", failedResponse ? "Copy response" : "Copy JSON"); + copy.type = "button"; + copy.disabled = failedResponse && !failedResponse.rawText; + copy.addEventListener("click", function () { + if (!navigator.clipboard || !navigator.clipboard.writeText) { + showToast("Clipboard access is unavailable in this browser."); + return; + } + navigator.clipboard.writeText(copiedResponse).then(function () { + copy.textContent = "Copied"; + showToast(failedResponse ? "Response copied to clipboard." : "JSON copied to clipboard."); + window.setTimeout(function () { copy.textContent = failedResponse ? "Copy response" : "Copy JSON"; }, 1600); + }).catch(function () { showToast("Could not copy the response. Select the text and copy it manually."); }); + }); + toolbar.append(copy); + const pre = createElement("pre"); + const code = createElement("code", "", displayedResponse); + pre.append(code); + shell.append(toolbar, pre); + return shell; + } + + function flattenForSearch(name, value) { + try { + return (name + " " + JSON.stringify(value)).toLocaleLowerCase(); + } catch (_error) { + return String(name).toLocaleLowerCase(); + } + } + + function renderModel(name, model) { + const details = createElement("details", "model-item"); + const summary = createElement("summary", "model-summary"); + const title = createElement("div", "model-title"); + title.append(createElement("strong", "", name)); + title.append(createElement("small", "", isObject(model) && model.target ? "Alias target: " + model.target : "Deployed endpoint")); + const description = createElement("div", "model-description", isObject(model) && model.description ? model.description : "No description provided."); + const badges = createElement("div", "model-badges"); + if (isObject(model) && model.type !== undefined) badges.append(createElement("span", "tag", String(model.type))); + if (isObject(model) && model.version !== undefined) badges.append(createElement("span", "tag", "v" + String(model.version))); + summary.append(title, description, badges); + const body = createElement("div", "model-details"); + body.append(isObject(model) ? renderObject(model) : renderObject({ value: model })); + details.append(summary, body); + return details; + } + + function populateModelList(list, count, empty, entries, query) { + const normalized = query.trim().toLocaleLowerCase(); + const filtered = entries.filter(function (entry) { return !normalized || flattenForSearch(entry[0], entry[1]).includes(normalized); }); + list.replaceChildren(); + filtered.forEach(function (entry) { list.append(renderModel(entry[0], entry[1])); }); + count.textContent = normalized + ? filtered.length + " of " + entries.length + " models" + : entries.length + (entries.length === 1 ? " model" : " models"); + empty.hidden = filtered.length !== 0; + list.hidden = filtered.length === 0; + } + + function renderModelsFormatted(models) { + const wrapper = createElement("div"); + const entries = Object.entries(models).sort(function (left, right) { return left[0].localeCompare(right[0], undefined, { sensitivity: "base" }); }); + if (entries.length === 0) { + wrapper.append(createStateCard("0", "No models deployed", "This server is ready. Deploy a model and it will appear here.")); + return wrapper; + } + const toolbar = createElement("div", "models-toolbar"); + const searchWrap = createElement("label", "search-wrap"); + searchWrap.append(createSvgSearchIcon()); + const searchLabel = createElement("span", "visually-hidden", "Search deployed models"); + const input = createElement("input", "model-search"); + input.type = "search"; + input.placeholder = "Search names, descriptions, types, and metadata"; + input.value = sectionState.models.query; + input.autocomplete = "off"; + const count = createElement("span", "model-count"); + searchWrap.append(searchLabel, input); + toolbar.append(searchWrap, count); + const list = createElement("div", "model-list"); + const noResults = createElement("div", "state-card"); + noResults.append(createElement("div", "state-icon", "0")); + const noResultsCopy = createElement("div"); + noResultsCopy.append(createElement("h3", "", "No matching models")); + noResultsCopy.append(createElement("p", "", "Try another search term. Names and all model metadata are searchable.")); + noResults.append(noResultsCopy); + noResults.hidden = true; + input.addEventListener("input", function () { + sectionState.models.query = input.value; + populateModelList(list, count, noResults, entries, input.value); + }); + populateModelList(list, count, noResults, entries, input.value); + wrapper.append(toolbar, list, noResults); + return wrapper; + } + + function createStateCard(icon, title, message, retrySection) { + const state = createElement("div", "state-card"); + const inner = createElement("div"); + inner.append(createElement("div", "state-icon", icon)); + inner.append(createElement("h3", "", title)); + inner.append(createElement("p", "", message)); + if (retrySection) { + const retry = createElement("button", "retry-button", "Try again"); + retry.type = "button"; + retry.addEventListener("click", function () { loadSection(retrySection); }); + inner.append(retry); + } + state.append(inner); + return state; + } + + function createSkeleton(section) { + const container = createElement("div", section === "info" ? "skeleton-grid" : "skeleton-list"); + container.setAttribute("aria-label", section === "info" ? "Loading server information" : "Loading deployed models"); + for (let index = 0; index < 3; index += 1) { + const classes = section === "info" ? "skeleton" + (index === 0 ? " skeleton-wide" : "") : "skeleton skeleton-row"; + container.append(createElement("span", classes)); + } + return container; + } + + function errorContent(section, error) { + const noun = section === "info" ? "server information" : "deployed models"; + if (error.status === 401) return createStateCard("!", "Authentication required", "Sign in through your configured TabPy authentication flow to view " + noun + ".", section); + if (error.status === 403) return createStateCard("!", "Access not permitted", "Your authenticated session does not have permission to view " + noun + ".", section); + if (error.kind === "malformed") return createStateCard("{ }", "Unexpected response", "The server responded, but its " + noun + " payload was not valid JSON in the expected shape.", section); + return createStateCard("!", "Unable to load data", "TabPy could not load " + noun + ". Check the server connection and try again.", section); + } + + function renderSection(section) { + const state = sectionState[section]; + const target = document.getElementById(section === "info" ? "info-content" : "models-content"); + target.replaceChildren(); + target.setAttribute("aria-busy", String(state.status === "loading")); + if (state.status === "loading") { + target.append(createSkeleton(section)); + } else if (state.view === "json") { + const route = section === "info" ? "/info" : "/endpoints"; + target.append(createJsonView(state.data, route, state.status === "error" ? (state.error.response || { status: state.error.status }) : null)); + } else if (state.status === "error") { + target.append(errorContent(section, state.error)); + } else { + target.append(section === "info" ? renderInfoFormatted(state.data) : renderModelsFormatted(state.data)); + } + } + + async function fetchJson(resource) { + let response; + try { + response = await fetch(new URL(resource, resourceBase), { + method: "GET", + credentials: "same-origin", + cache: "no-store", + headers: { Accept: "application/json" } + }); + } catch (cause) { + throw { kind: "network", cause: cause }; + } + let rawText; + try { + rawText = await response.text(); + } catch (cause) { + throw { kind: "network", status: response.status, cause: cause }; + } + let data = null; + let isJson = false; + try { + data = JSON.parse(rawText); + isJson = true; + } catch (_error) { + // Keep the exact response text available to the JSON view. + } + const responseBody = { + status: response.status, + rawText: rawText, + data: data, + isJson: isJson + }; + if (!response.ok) throw { kind: "http", status: response.status, response: responseBody }; + if (!isJson || !isObject(data)) throw { kind: "malformed", status: response.status, response: responseBody }; + return data; + } + + async function loadSection(section) { + const state = sectionState[section]; + const announcer = document.getElementById(section + "-announcer"); + state.status = "loading"; + state.error = null; + announcer.textContent = section === "info" ? "Loading server information." : "Loading deployed models."; + renderSection(section); + try { + state.data = await fetchJson(section === "info" ? "info" : "endpoints"); + state.status = "success"; + announcer.textContent = section === "info" + ? "Server information loaded." + : Object.keys(state.data).length + " deployed models loaded."; + if (section === "info") updateHero(state.data, null); + } catch (error) { + state.status = "error"; + state.error = error || { kind: "network" }; + announcer.textContent = state.error.status === 401 + ? "Authentication is required to load this section." + : "This section could not be loaded."; + if (section === "info") updateHero(null, state.error); + } + renderSection(section); + } + + function initializeViewSwitchers() { + document.querySelectorAll(".view-button").forEach(function (button) { + button.addEventListener("click", function () { + const section = button.dataset.section; + const view = button.dataset.view; + sectionState[section].view = view; + document.querySelectorAll('.view-button[data-section="' + section + '"]').forEach(function (peer) { + const selected = peer === button; + peer.classList.toggle("is-active", selected); + peer.setAttribute("aria-pressed", String(selected)); + }); + renderSection(section); + }); + }); + } + + function showToast(message) { + const toast = document.getElementById("toast"); + window.clearTimeout(toastTimer); + toast.textContent = message; + toast.classList.add("is-visible"); + toastTimer = window.setTimeout(function () { toast.classList.remove("is-visible"); }, 2600); + } + + function initializeApp() { + initializeTheme(); + initializeViewSwitchers(); + loadSection("info"); + loadSection("models"); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initializeApp, { once: true }); + } else { + initializeApp(); + } +}()); + + -

TabPy Server Info:

-

+ + +

+ +
+
+ + +
+
TabPy workspace
+

TabPy Server

+

Live server details and deployed Python models, all in one place.

+
Connecting to server
+
+
+ +
+
+
+
+
ServerGET /info
+

Server information

+

Identity, configuration, and runtime capabilities advertised by this server.

+
+
+ + +
+
+
Loading server information.
+
+
+
+
-

Deployed Models:

-

+

+
+
+
ModelsGET /endpoints
+

Deployed models

+

Search and inspect every Python model currently available to TabPy.

+
+
+ + +
+
+
Loading deployed models.
+
+
+
+
-

Useful links:

- +
+
Resources

Keep exploring

+ +
+
+
+ +
- + diff --git a/tests/integration/test_url.py b/tests/integration/test_url.py index d2744b42..3d8d93ee 100755 --- a/tests/integration/test_url.py +++ b/tests/integration/test_url.py @@ -18,9 +18,18 @@ def test_notexistent_url(self): self.assertEqual(404, res.status) - def test_static_page(self): - conn = self._get_connection() - conn.request("GET", "/") - res = conn.getresponse() - - self.assertEqual(200, res.status) + def test_static_page(self): + conn = self._get_connection() + conn.request("GET", "/") + res = conn.getresponse() + page = res.read() + + self.assertEqual(200, res.status) + self.assertIn("text/html", res.getheader("Content-Type")) + self.assertIn(b"