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