Add graph explorer UI shell

This commit is contained in:
2026-05-18 17:11:36 +02:00
parent d2056c9046
commit d31b1376c8
7 changed files with 396 additions and 3 deletions

View File

@@ -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
``` ```

View File

@@ -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
``` ```

View File

@@ -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
``` ```

View 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("&", "&amp;").replaceAll("<", "&lt;")
.replaceAll(">", "&gt;").replaceAll('"', "&quot;");
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>
"""

View File

@@ -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)

View File

@@ -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()

View File

@@ -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"
``` ```