From d31b1376c85ebb77ce1ec20d895e2b278dc6b42f Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 18 May 2026 17:11:36 +0200 Subject: [PATCH] Add graph explorer UI shell --- README.md | 1 + docs/graph-explorer-contract.md | 1 + docs/registry-api.md | 1 + railiance_fabric/graph_explorer_ui.py | 364 ++++++++++++++++++ railiance_fabric/server.py | 22 +- tests/test_graph_explorer.py | 8 + ...RAIL-FAB-WP-0008-interactive-fabric-map.md | 2 +- 7 files changed, 396 insertions(+), 3 deletions(-) create mode 100644 railiance_fabric/graph_explorer_ui.py diff --git a/README.md b/README.md index 0ff817a..1d59623 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ GET /repositories/{repo_slug}/inventory GET /repositories/{repo_slug}/snapshots GET /repositories/{repo_slug}/snapshots/diff GET /search?q=jsonschema +GET /ui/graph-explorer GET /exports/graph-explorer/manifest GET /exports/graph-explorer ``` diff --git a/docs/graph-explorer-contract.md b/docs/graph-explorer-contract.md index 8ba69ea..961c24c 100644 --- a/docs/graph-explorer-contract.md +++ b/docs/graph-explorer-contract.md @@ -19,6 +19,7 @@ payload with stable fields. The registry service exposes the first Fabric projection: ```text +GET /ui/graph-explorer GET /exports/graph-explorer/manifest GET /exports/graph-explorer ``` diff --git a/docs/registry-api.md b/docs/registry-api.md index 904ee54..8d58bf6 100644 --- a/docs/registry-api.md +++ b/docs/registry-api.md @@ -64,6 +64,7 @@ GET /exports/state-hub GET /exports/backstage GET /exports/xregistry GET /exports/libraries/xregistry +GET /ui/graph-explorer GET /exports/graph-explorer/manifest GET /exports/graph-explorer ``` diff --git a/railiance_fabric/graph_explorer_ui.py b/railiance_fabric/graph_explorer_ui.py new file mode 100644 index 0000000..59aae2d --- /dev/null +++ b/railiance_fabric/graph_explorer_ui.py @@ -0,0 +1,364 @@ +from __future__ import annotations + + +def graph_explorer_page() -> str: + return """ + + + + + Fabric Map + + + +
+
+ + + + + + + + +
+
+
+ + +
+ +
+
+ + + + +""" diff --git a/railiance_fabric/server.py b/railiance_fabric/server.py index b200f0d..c4c4917 100644 --- a/railiance_fabric/server.py +++ b/railiance_fabric/server.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse import json import sys +from dataclasses import dataclass from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -10,6 +11,7 @@ from typing import Any from urllib.parse import parse_qs, urlparse from .graph_explorer import fabric_graph_explorer_manifest, fabric_graph_explorer_payload +from .graph_explorer_ui import graph_explorer_page from .registry import ( RegistryError, RegistryStore, @@ -24,6 +26,11 @@ from .registry import ( ) +@dataclass(frozen=True) +class HtmlResponse: + body: str + + class RegistryHandler(BaseHTTPRequestHandler): store: RegistryStore @@ -40,6 +47,8 @@ class RegistryHandler(BaseHTTPRequestHandler): parts = _parts(path) if path == "/health": return HTTPStatus.OK, {"status": "ok"} + if parts == ["ui", "graph-explorer"]: + return HTTPStatus.OK, HtmlResponse(graph_explorer_page()) if path == "/status": return HTTPStatus.OK, self.store.status() if parts == ["repositories"]: @@ -129,7 +138,10 @@ class RegistryHandler(BaseHTTPRequestHandler): query = parse_qs(parsed.query) try: 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: self._send_json(exc.status_code, {"error": exc.message}) except json.JSONDecodeError as exc: @@ -149,8 +161,14 @@ class RegistryHandler(BaseHTTPRequestHandler): def _send_json(self, status: int, body: Any) -> None: 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_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(payload))) self.end_headers() self.wfile.write(payload) diff --git a/tests/test_graph_explorer.py b/tests/test_graph_explorer.py index 02da7e0..15c877b 100644 --- a/tests/test_graph_explorer.py +++ b/tests/test_graph_explorer.py @@ -177,11 +177,19 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None: manifest = json.loads(response.read()) with urllib.request.urlopen(f"{base_url}/exports/graph-explorer", timeout=5) as response: 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-payload.schema.yaml", payload) assert manifest["id"] == "railiance-fabric.registry-map" 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: server.shutdown() server.server_close() diff --git a/workplans/RAIL-FAB-WP-0008-interactive-fabric-map.md b/workplans/RAIL-FAB-WP-0008-interactive-fabric-map.md index 6470126..8df996a 100644 --- a/workplans/RAIL-FAB-WP-0008-interactive-fabric-map.md +++ b/workplans/RAIL-FAB-WP-0008-interactive-fabric-map.md @@ -205,7 +205,7 @@ Acceptance notes: ```task id: RAIL-FAB-WP-0008-T04 -status: todo +status: in_progress priority: high state_hub_task_id: "75c1f234-026c-44ed-9c88-db39653b81e0" ```