generated from coulomb/repo-seed
Add graph explorer UI shell
This commit is contained in:
@@ -95,6 +95,7 @@ GET /repositories/{repo_slug}/inventory
|
|||||||
GET /repositories/{repo_slug}/snapshots
|
GET /repositories/{repo_slug}/snapshots
|
||||||
GET /repositories/{repo_slug}/snapshots/diff
|
GET /repositories/{repo_slug}/snapshots/diff
|
||||||
GET /search?q=jsonschema
|
GET /search?q=jsonschema
|
||||||
|
GET /ui/graph-explorer
|
||||||
GET /exports/graph-explorer/manifest
|
GET /exports/graph-explorer/manifest
|
||||||
GET /exports/graph-explorer
|
GET /exports/graph-explorer
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ payload with stable fields.
|
|||||||
The registry service exposes the first Fabric projection:
|
The registry service exposes the first Fabric projection:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
GET /ui/graph-explorer
|
||||||
GET /exports/graph-explorer/manifest
|
GET /exports/graph-explorer/manifest
|
||||||
GET /exports/graph-explorer
|
GET /exports/graph-explorer
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ GET /exports/state-hub
|
|||||||
GET /exports/backstage
|
GET /exports/backstage
|
||||||
GET /exports/xregistry
|
GET /exports/xregistry
|
||||||
GET /exports/libraries/xregistry
|
GET /exports/libraries/xregistry
|
||||||
|
GET /ui/graph-explorer
|
||||||
GET /exports/graph-explorer/manifest
|
GET /exports/graph-explorer/manifest
|
||||||
GET /exports/graph-explorer
|
GET /exports/graph-explorer
|
||||||
```
|
```
|
||||||
|
|||||||
364
railiance_fabric/graph_explorer_ui.py
Normal file
364
railiance_fabric/graph_explorer_ui.py
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def graph_explorer_page() -> str:
|
||||||
|
return """<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Fabric Map</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
--bg: #f7f8fa;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--line: #d6dce5;
|
||||||
|
--text: #172033;
|
||||||
|
--muted: #667085;
|
||||||
|
--accent: #0f766e;
|
||||||
|
--accent-2: #2563eb;
|
||||||
|
--warn: #b45309;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; margin: 0; }
|
||||||
|
body { background: var(--bg); color: var(--text); font-size: 14px; }
|
||||||
|
.shell { display: grid; grid-template-rows: auto 1fr; height: 100vh; min-height: 620px; }
|
||||||
|
.toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 1.2fr) repeat(3, minmax(120px, .6fr)) auto auto auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
.main { display: grid; grid-template-columns: minmax(0, 1fr) 340px; min-height: 0; }
|
||||||
|
.canvas-wrap { position: relative; min-width: 0; min-height: 0; }
|
||||||
|
#graph-canvas { position: absolute; inset: 0; }
|
||||||
|
.side {
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.side h1 { font-size: 17px; line-height: 1.2; margin: 0 0 10px; }
|
||||||
|
.section { border-top: 1px solid var(--line); padding-top: 12px; margin-top: 12px; }
|
||||||
|
.section:first-child { border-top: 0; padding-top: 0; margin-top: 0; }
|
||||||
|
label { color: var(--muted); display: grid; gap: 4px; font-size: 12px; }
|
||||||
|
input, select, button {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
button.primary { background: var(--accent); border-color: var(--accent); color: #ffffff; }
|
||||||
|
button:disabled { color: #98a2b3; cursor: default; }
|
||||||
|
.button-row { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.meta { color: var(--muted); font-size: 12px; margin: 0; }
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--muted);
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
margin: 0 4px 4px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.detail-list { display: grid; gap: 6px; margin: 8px 0 0; padding: 0; list-style: none; }
|
||||||
|
.detail-list li { overflow-wrap: anywhere; }
|
||||||
|
.popup {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 4;
|
||||||
|
max-width: 260px;
|
||||||
|
display: none;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 16px 40px rgba(23, 32, 51, .14);
|
||||||
|
padding: 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.popup strong { display: block; margin-bottom: 4px; }
|
||||||
|
.notice { color: var(--warn); padding: 14px; }
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.shell { min-height: 760px; }
|
||||||
|
.toolbar { grid-template-columns: 1fr 1fr; }
|
||||||
|
.main { grid-template-columns: 1fr; grid-template-rows: minmax(420px, 1fr) 330px; }
|
||||||
|
.side { border-left: 0; border-top: 1px solid var(--line); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<section class="shell">
|
||||||
|
<div class="toolbar">
|
||||||
|
<label>Search <input id="search" autocomplete="off"></label>
|
||||||
|
<label>Layer <select id="layer-filter"><option value="">Any</option></select></label>
|
||||||
|
<label>Review <select id="review-filter"><option value="">Any</option><option>accepted</option><option>candidate</option></select></label>
|
||||||
|
<label>Unresolved <select id="unresolved-filter"><option value="">Any</option><option value="true">Only</option></select></label>
|
||||||
|
<button type="button" data-action="fit">Fit</button>
|
||||||
|
<button type="button" data-action="focus">Focus</button>
|
||||||
|
<button type="button" data-action="clear" class="primary">Reset</button>
|
||||||
|
<span id="counts" class="meta"></span>
|
||||||
|
</div>
|
||||||
|
<main class="main">
|
||||||
|
<div class="canvas-wrap">
|
||||||
|
<div id="graph-canvas" role="img" aria-label="Interactive Fabric graph"></div>
|
||||||
|
<div id="popup" class="popup"></div>
|
||||||
|
</div>
|
||||||
|
<aside class="side">
|
||||||
|
<section class="section">
|
||||||
|
<h1 id="detail-title">Fabric Map</h1>
|
||||||
|
<p id="detail-summary" class="meta">No selection</p>
|
||||||
|
<div id="detail-pills"></div>
|
||||||
|
<ul id="detail-list" class="detail-list"></ul>
|
||||||
|
</section>
|
||||||
|
<section class="section">
|
||||||
|
<div class="button-row">
|
||||||
|
<button type="button" data-override="show">Show</button>
|
||||||
|
<button type="button" data-override="blur">Blur</button>
|
||||||
|
<button type="button" data-override="hide">Hide</button>
|
||||||
|
<button type="button" data-action="reset-overrides">Clear Overrides</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="section">
|
||||||
|
<p id="hidden-summary" class="meta">Hidden 0</p>
|
||||||
|
<div id="legend"></div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
</section>
|
||||||
|
<script src="https://unpkg.com/cytoscape@3.28.1/dist/cytoscape.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const manifestUrl = "/exports/graph-explorer/manifest";
|
||||||
|
const graphUrl = "/exports/graph-explorer";
|
||||||
|
const canvas = document.getElementById("graph-canvas");
|
||||||
|
const popup = document.getElementById("popup");
|
||||||
|
const searchInput = document.getElementById("search");
|
||||||
|
const layerFilter = document.getElementById("layer-filter");
|
||||||
|
const reviewFilter = document.getElementById("review-filter");
|
||||||
|
const unresolvedFilter = document.getElementById("unresolved-filter");
|
||||||
|
const counts = document.getElementById("counts");
|
||||||
|
const hiddenSummary = document.getElementById("hidden-summary");
|
||||||
|
const detailTitle = document.getElementById("detail-title");
|
||||||
|
const detailSummary = document.getElementById("detail-summary");
|
||||||
|
const detailPills = document.getElementById("detail-pills");
|
||||||
|
const detailList = document.getElementById("detail-list");
|
||||||
|
const legend = document.getElementById("legend");
|
||||||
|
let cy = null;
|
||||||
|
let selected = null;
|
||||||
|
let focusSet = null;
|
||||||
|
let manualOverrides = {};
|
||||||
|
let layerColors = {};
|
||||||
|
|
||||||
|
const escapeHtml = (value) => String(value ?? "")
|
||||||
|
.replaceAll("&", "&").replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">").replaceAll('"', """);
|
||||||
|
|
||||||
|
const elementText = (data) => [
|
||||||
|
data.id, data.stableKey, data.label, data.name, data.description,
|
||||||
|
data.repo, data.domain, data.kind, data.layer, data.edgeType
|
||||||
|
].join(" ").toLowerCase();
|
||||||
|
|
||||||
|
const matchesFilters = (element) => {
|
||||||
|
const data = element.data();
|
||||||
|
const text = searchInput.value.trim().toLowerCase();
|
||||||
|
if (text && !elementText(data).includes(text)) return false;
|
||||||
|
if (layerFilter.value && data.layer !== layerFilter.value) return false;
|
||||||
|
if (reviewFilter.value && data.reviewState !== reviewFilter.value) return false;
|
||||||
|
if (unresolvedFilter.value === "true" && data.unresolved !== true) return false;
|
||||||
|
if (focusSet && !focusSet.has(element.id())) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyFilters = () => {
|
||||||
|
if (!cy) return;
|
||||||
|
const hiddenNodes = new Set();
|
||||||
|
cy.nodes().forEach((node) => {
|
||||||
|
let state = matchesFilters(node) ? "show" : "hide";
|
||||||
|
if (manualOverrides[node.id()]) state = manualOverrides[node.id()];
|
||||||
|
node.data("displayState", state);
|
||||||
|
node.toggleClass("display-blur", state === "blur");
|
||||||
|
node.style("display", state === "hide" ? "none" : "element");
|
||||||
|
if (state === "hide") hiddenNodes.add(node.id());
|
||||||
|
});
|
||||||
|
cy.edges().forEach((edge) => {
|
||||||
|
let state = matchesFilters(edge) ? "show" : "hide";
|
||||||
|
if (hiddenNodes.has(edge.data("source")) || hiddenNodes.has(edge.data("target"))) state = "hide";
|
||||||
|
if (manualOverrides[edge.id()]) state = manualOverrides[edge.id()];
|
||||||
|
edge.data("displayState", state);
|
||||||
|
edge.toggleClass("display-blur", state === "blur");
|
||||||
|
edge.style("display", state === "hide" ? "none" : "element");
|
||||||
|
});
|
||||||
|
const visibleNodes = cy.nodes().filter((node) => node.style("display") !== "none").length;
|
||||||
|
const visibleEdges = cy.edges().filter((edge) => edge.style("display") !== "none").length;
|
||||||
|
const hidden = cy.elements().length - visibleNodes - visibleEdges;
|
||||||
|
counts.textContent = `${visibleNodes} nodes / ${visibleEdges} edges`;
|
||||||
|
hiddenSummary.textContent = `Hidden ${hidden}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showDetails = (element) => {
|
||||||
|
selected = element || null;
|
||||||
|
if (!element) {
|
||||||
|
detailTitle.textContent = "Fabric Map";
|
||||||
|
detailSummary.textContent = "No selection";
|
||||||
|
detailPills.innerHTML = "";
|
||||||
|
detailList.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = element.data();
|
||||||
|
detailTitle.textContent = data.name || data.label || data.id;
|
||||||
|
detailSummary.textContent = data.description || data.id;
|
||||||
|
detailPills.innerHTML = ["kind", "layer", "repo", "reviewState", "displayState"]
|
||||||
|
.map((field) => data[field] ? `<span class="pill">${escapeHtml(data[field])}</span>` : "")
|
||||||
|
.join("");
|
||||||
|
const links = data.deepLinks || {};
|
||||||
|
const refs = data.sourceReferences || [];
|
||||||
|
const rows = [
|
||||||
|
["id", data.id],
|
||||||
|
["source", data.source],
|
||||||
|
["target", data.target],
|
||||||
|
["edge", data.edgeType],
|
||||||
|
["strength", data.strength],
|
||||||
|
...Object.entries(links),
|
||||||
|
...refs.map((ref) => [ref.label || ref.kind || "source", ref.path || ref.url || ref.ref || ""])
|
||||||
|
];
|
||||||
|
detailList.innerHTML = rows
|
||||||
|
.filter(([, value]) => value)
|
||||||
|
.map(([key, value]) => `<li><strong>${escapeHtml(key)}</strong> ${escapeHtml(value)}</li>`)
|
||||||
|
.join("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyFocus = () => {
|
||||||
|
if (!cy || !selected) return;
|
||||||
|
const collection = selected.union(selected.neighborhood()).union(selected.neighborhood().neighborhood());
|
||||||
|
focusSet = new Set(collection.map((item) => item.id()));
|
||||||
|
applyFilters();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderLegend = (layers) => {
|
||||||
|
legend.innerHTML = layers.map((layer) =>
|
||||||
|
`<span class="pill"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${escapeHtml(layer.color || "#667085")}"></span>${escapeHtml(layer.label)}</span>`
|
||||||
|
).join("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const boot = async () => {
|
||||||
|
const [manifest, payload] = await Promise.all([
|
||||||
|
fetch(manifestUrl).then((response) => response.json()),
|
||||||
|
fetch(graphUrl).then((response) => response.json())
|
||||||
|
]);
|
||||||
|
layerColors = Object.fromEntries((manifest.layers || []).map((layer) => [layer.id, layer.color]));
|
||||||
|
(manifest.layers || []).forEach((layer) => {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = layer.id;
|
||||||
|
option.textContent = layer.label;
|
||||||
|
layerFilter.appendChild(option);
|
||||||
|
});
|
||||||
|
renderLegend(manifest.layers || []);
|
||||||
|
const elements = (payload.elements || []).map((element) => ({
|
||||||
|
...element,
|
||||||
|
data: {...element.data, color: layerColors[element.data.layer] || "#667085"}
|
||||||
|
}));
|
||||||
|
cy = cytoscape({
|
||||||
|
container: canvas,
|
||||||
|
elements,
|
||||||
|
layout: {name: "cose", animate: false, randomize: false},
|
||||||
|
style: [
|
||||||
|
{selector: "node", style: {
|
||||||
|
"background-color": "data(color)",
|
||||||
|
"border-color": "#172033",
|
||||||
|
"border-width": 1,
|
||||||
|
"color": "#172033",
|
||||||
|
"font-size": 11,
|
||||||
|
"height": "data(visualSize)",
|
||||||
|
"label": "data(label)",
|
||||||
|
"text-background-color": "#ffffff",
|
||||||
|
"text-background-opacity": .85,
|
||||||
|
"text-background-padding": 2,
|
||||||
|
"text-wrap": "wrap",
|
||||||
|
"text-max-width": 130,
|
||||||
|
"width": "data(visualSize)"
|
||||||
|
}},
|
||||||
|
{selector: "edge", style: {
|
||||||
|
"curve-style": "bezier",
|
||||||
|
"line-color": "#98a2b3",
|
||||||
|
"target-arrow-color": "#98a2b3",
|
||||||
|
"target-arrow-shape": "triangle",
|
||||||
|
"width": "data(edgeWidth)"
|
||||||
|
}},
|
||||||
|
{selector: "edge[strength = 'strong']", style: {"line-color": "#344054", "target-arrow-color": "#344054"}},
|
||||||
|
{selector: "edge[strength = 'weak']", style: {"line-style": "dotted"}},
|
||||||
|
{selector: ".display-blur", style: {"opacity": .24, "label": ""}},
|
||||||
|
{selector: ".display-blur:selected, .display-blur.hover", style: {"opacity": .78, "label": "data(label)"}},
|
||||||
|
{selector: ":selected", style: {"border-width": 4, "border-color": "#111827", "line-color": "#111827", "target-arrow-color": "#111827"}}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
cy.on("tap", "node, edge", (event) => showDetails(event.target));
|
||||||
|
cy.on("tap", (event) => { if (event.target === cy) showDetails(null); });
|
||||||
|
cy.on("mouseover", "node, edge", (event) => {
|
||||||
|
event.target.addClass("hover");
|
||||||
|
const data = event.target.data();
|
||||||
|
popup.innerHTML = `<strong>${escapeHtml(data.name || data.label || data.id)}</strong><span class="meta">${escapeHtml(data.kind)} / ${escapeHtml(data.layer)}</span>`;
|
||||||
|
popup.style.left = `${Math.min(event.renderedPosition.x + 14, canvas.clientWidth - 270)}px`;
|
||||||
|
popup.style.top = `${Math.max(10, event.renderedPosition.y + 14)}px`;
|
||||||
|
popup.style.display = "block";
|
||||||
|
});
|
||||||
|
cy.on("mouseout", "node, edge", (event) => {
|
||||||
|
event.target.removeClass("hover");
|
||||||
|
popup.style.display = "none";
|
||||||
|
});
|
||||||
|
applyFilters();
|
||||||
|
};
|
||||||
|
|
||||||
|
[searchInput, layerFilter, reviewFilter, unresolvedFilter].forEach((control) => {
|
||||||
|
control.addEventListener("input", () => { focusSet = null; applyFilters(); });
|
||||||
|
});
|
||||||
|
document.querySelector("[data-action='fit']").addEventListener("click", () => cy && cy.fit(cy.elements(":visible"), 48));
|
||||||
|
document.querySelector("[data-action='focus']").addEventListener("click", applyFocus);
|
||||||
|
document.querySelector("[data-action='clear']").addEventListener("click", () => {
|
||||||
|
searchInput.value = "";
|
||||||
|
layerFilter.value = "";
|
||||||
|
reviewFilter.value = "";
|
||||||
|
unresolvedFilter.value = "";
|
||||||
|
focusSet = null;
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
document.querySelector("[data-action='reset-overrides']").addEventListener("click", () => {
|
||||||
|
manualOverrides = {};
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
document.querySelectorAll("[data-override]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
if (!selected) return;
|
||||||
|
manualOverrides[selected.id()] = button.dataset.override;
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (!window.cytoscape) {
|
||||||
|
canvas.innerHTML = "<p class='notice'>Cytoscape.js could not be loaded.</p>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boot().catch((error) => {
|
||||||
|
canvas.innerHTML = `<p class='notice'>${escapeHtml(error.message)}</p>`;
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -10,6 +11,7 @@ from typing import Any
|
|||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
from .graph_explorer import fabric_graph_explorer_manifest, fabric_graph_explorer_payload
|
from .graph_explorer import fabric_graph_explorer_manifest, fabric_graph_explorer_payload
|
||||||
|
from .graph_explorer_ui import graph_explorer_page
|
||||||
from .registry import (
|
from .registry import (
|
||||||
RegistryError,
|
RegistryError,
|
||||||
RegistryStore,
|
RegistryStore,
|
||||||
@@ -24,6 +26,11 @@ from .registry import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HtmlResponse:
|
||||||
|
body: str
|
||||||
|
|
||||||
|
|
||||||
class RegistryHandler(BaseHTTPRequestHandler):
|
class RegistryHandler(BaseHTTPRequestHandler):
|
||||||
store: RegistryStore
|
store: RegistryStore
|
||||||
|
|
||||||
@@ -40,6 +47,8 @@ class RegistryHandler(BaseHTTPRequestHandler):
|
|||||||
parts = _parts(path)
|
parts = _parts(path)
|
||||||
if path == "/health":
|
if path == "/health":
|
||||||
return HTTPStatus.OK, {"status": "ok"}
|
return HTTPStatus.OK, {"status": "ok"}
|
||||||
|
if parts == ["ui", "graph-explorer"]:
|
||||||
|
return HTTPStatus.OK, HtmlResponse(graph_explorer_page())
|
||||||
if path == "/status":
|
if path == "/status":
|
||||||
return HTTPStatus.OK, self.store.status()
|
return HTTPStatus.OK, self.store.status()
|
||||||
if parts == ["repositories"]:
|
if parts == ["repositories"]:
|
||||||
@@ -129,7 +138,10 @@ class RegistryHandler(BaseHTTPRequestHandler):
|
|||||||
query = parse_qs(parsed.query)
|
query = parse_qs(parsed.query)
|
||||||
try:
|
try:
|
||||||
status, body = action(parsed.path, query)
|
status, body = action(parsed.path, query)
|
||||||
self._send_json(int(status), body)
|
if isinstance(body, HtmlResponse):
|
||||||
|
self._send_text(int(status), body.body, "text/html; charset=utf-8")
|
||||||
|
else:
|
||||||
|
self._send_json(int(status), body)
|
||||||
except RegistryError as exc:
|
except RegistryError as exc:
|
||||||
self._send_json(exc.status_code, {"error": exc.message})
|
self._send_json(exc.status_code, {"error": exc.message})
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
@@ -149,8 +161,14 @@ class RegistryHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def _send_json(self, status: int, body: Any) -> None:
|
def _send_json(self, status: int, body: Any) -> None:
|
||||||
payload = json.dumps(body, indent=2, sort_keys=True).encode("utf-8")
|
payload = json.dumps(body, indent=2, sort_keys=True).encode("utf-8")
|
||||||
|
self._send_bytes(status, payload, "application/json; charset=utf-8")
|
||||||
|
|
||||||
|
def _send_text(self, status: int, body: str, content_type: str) -> None:
|
||||||
|
self._send_bytes(status, body.encode("utf-8"), content_type)
|
||||||
|
|
||||||
|
def _send_bytes(self, status: int, payload: bytes, content_type: str) -> None:
|
||||||
self.send_response(status)
|
self.send_response(status)
|
||||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
self.send_header("Content-Type", content_type)
|
||||||
self.send_header("Content-Length", str(len(payload)))
|
self.send_header("Content-Length", str(len(payload)))
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(payload)
|
self.wfile.write(payload)
|
||||||
|
|||||||
@@ -177,11 +177,19 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
|
|||||||
manifest = json.loads(response.read())
|
manifest = json.loads(response.read())
|
||||||
with urllib.request.urlopen(f"{base_url}/exports/graph-explorer", timeout=5) as response:
|
with urllib.request.urlopen(f"{base_url}/exports/graph-explorer", timeout=5) as response:
|
||||||
payload = json.loads(response.read())
|
payload = json.loads(response.read())
|
||||||
|
with urllib.request.urlopen(f"{base_url}/ui/graph-explorer", timeout=5) as response:
|
||||||
|
page = response.read().decode("utf-8")
|
||||||
|
content_type = response.headers["Content-Type"]
|
||||||
|
|
||||||
_validate_schema("graph-explorer-manifest.schema.yaml", manifest)
|
_validate_schema("graph-explorer-manifest.schema.yaml", manifest)
|
||||||
_validate_schema("graph-explorer-payload.schema.yaml", payload)
|
_validate_schema("graph-explorer-payload.schema.yaml", payload)
|
||||||
assert manifest["id"] == "railiance-fabric.registry-map"
|
assert manifest["id"] == "railiance-fabric.registry-map"
|
||||||
assert payload["metrics"]["registered_only_repo_count"] == 1
|
assert payload["metrics"]["registered_only_repo_count"] == 1
|
||||||
|
assert content_type.startswith("text/html")
|
||||||
|
assert 'id="graph-canvas"' in page
|
||||||
|
assert "cytoscape.min.js" in page
|
||||||
|
assert "/exports/graph-explorer/manifest" in page
|
||||||
|
assert 'data-override="hide"' in page
|
||||||
finally:
|
finally:
|
||||||
server.shutdown()
|
server.shutdown()
|
||||||
server.server_close()
|
server.server_close()
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ Acceptance notes:
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAIL-FAB-WP-0008-T04
|
id: RAIL-FAB-WP-0008-T04
|
||||||
status: todo
|
status: in_progress
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "75c1f234-026c-44ed-9c88-db39653b81e0"
|
state_hub_task_id: "75c1f234-026c-44ed-9c88-db39653b81e0"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user