scripts.graph_browser

Reference › scripts › graph_browser

scripts/graph_browser.py is an interactive graph browser in the style of Neo4j Browser for the project's graphs. A human starts it; it is a FastAPI app served by uvicorn. main takes --host (default 127.0.0.1) and --port (default 8090), and runs uvicorn with the h11 HTTP implementation, because the default auto mode imports httptools, which re-enables the GIL process-wide (SPEC-023); ast_daemon carries the same pin. See also the graph browser page.

The app serves one self-contained HTML page at / (index) and a JSON API: api_meta returns node labels and relationship types with counts and assigned colours, api_nodes lists nodes with an optional substring filter on the display name, and api_expand returns a node's neighbours and relationships, capped by limit. Each request names a graph kind; _normalize_graph_kind folds the deprecated neo4j alias onto kg, _resolve_memgraph_uri and _get_driver obtain a driver through orchestrata_ultimate.core.graph_factory, and _open_session binds a session to the database the graph kind names. _jsonable and _trim_props coerce Bolt property values to JSON-safe, shortened values. The script is read-only against the graph.

At a glance

Source scripts/graph_browser.py
Lines of code 402
Classes 6
Functions 13
Imports (internal) orchestrata_ultimate.core.graph_factory
Imported by none
Tests that import it none

Class diagram

Class diagram scripts.graph_browser

Dependency diagram

Dependency diagram scripts.graph_browser

API

Classes

LabelMeta (pydantic)

Bases: BaseModel · Line 186

Attribute Type Default
label str
count int
color str

RelMeta (pydantic)

Bases: BaseModel · Line 192

Attribute Type Default
type str
count int

MetaResponse (pydantic)

Bases: BaseModel · Line 197

Attribute Type Default
labels list[LabelMeta]
relationships list[RelMeta]

NodeSummary (pydantic)

Bases: BaseModel · Line 202

Attribute Type Default
id int
labels list[str]
name str
properties dict[str, Any]

EdgeSummary (pydantic)

Bases: BaseModel · Line 209

Attribute Type Default
id int
source int
target int
type str

ExpandResponse (pydantic)

Bases: BaseModel · Line 216

Attribute Type Default
nodes list[NodeSummary]
edges list[EdgeSummary]
truncated bool

Functions

Function Signature Summary
_normalize_graph_kind _normalize_graph_kind(graph: GraphKind) -> Literal['memgraph', 'kg'] Fold the deprecated neo4j alias onto the canonical kg value.
_resolve_memgraph_uri _resolve_memgraph_uri(env: Mapping[str, str] | None = None) -> str Discover the current Memgraph MAIN via the HA controller, falling back to the first URI in MEMGRAPH_URIS, then MEMGRAPH_URI.
_get_driver _get_driver(graph: GraphKind) -> Any
_open_session _open_session(driver: Any, graph: GraphKind) -> Any Open a session bound to the database graph actually names.
_known_labels _known_labels(driver: Any, graph: GraphKind) -> set[str] The set of node labels actually present in the database.
_display_name _display_name(props: dict[str, Any], node_id: int, labels: list[str]) -> str
_jsonable _jsonable(value: Any) -> Any Coerce a Bolt property value into something the JSON serializer accepts.
_trim_props _trim_props(props: dict[str, Any]) -> dict[str, Any]
index app.get index() -> str Serve the self-contained graph-browser HTML app.
api_meta app.get api_meta(graph: GraphKind = Query('memgraph', description="'memgraph' or 'kg' ('neo4j' …) -> MetaResponse Node labels with counts + assigned colors, relationship types with counts.
api_nodes app.get api_nodes(label: str = Query(..., description='Node label to seed from'), limit: int = Query(50, ge=1, le=500), q: str | None = Query(None, description='Substring filter on the display na…, graph: GraphKind = Query('memgraph', description="'memgraph' or 'kg' ('neo4j' …) -> list[NodeSummary] Seed nodes for a given label, optionally filtered by a name substring.
api_expand app.get api_expand(node_id: int, limit: int = Query(100, ge=1, le=500), graph: GraphKind = Query('memgraph', description="'memgraph' or 'kg' ('neo4j' …) -> ExpandResponse Neighbors + relationships of a node, capped at limit edges.
main main() -> None

Source

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/scripts/graph_browser.py

"""Interactive graph browser (Neo4j-Browser-style) for the project's graphs.

Serves a self-contained d3-force HTML app plus a small read-only JSON API
over the Memgraph semantic graph (default) or the operational knowledge graph
(``?graph=kg``). Connects via ``core.graph_factory`` only -- direct
``GraphDatabase.driver`` calls are banned by ``scripts/architecture_checker.py``.

``?graph=neo4j`` is accepted as a DEPRECATED alias for ``?graph=kg`` (the
knowledge graph this browser reads has run on the orchgraph/Memgraph cluster
since the 2026-07-27 Neo4j decommission; the query-param name is kept working
so existing bookmarks/links do not break).

Run:
    uv run python scripts/graph_browser.py [--host 127.0.0.1] [--port 8090]
Then open http://127.0.0.1:8090/ in a browser.
"""

from __future__ import annotations

import argparse
import os
from collections.abc import Mapping
from pathlib import Path
from typing import Any, Literal

import httpx
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel

from orchestrata_ultimate.core.graph_factory import (
    NO_AUTH,
    create_graph,
    resolve_kg_database,
)

_HERE = Path(__file__).resolve().parent
_STATIC_DIR = _HERE / "static"
_HTML_PATH = _STATIC_DIR / "graph_browser.html"

GraphKind = Literal["memgraph", "kg", "neo4j"]


def _normalize_graph_kind(graph: GraphKind) -> Literal["memgraph", "kg"]:
    """Fold the deprecated ``neo4j`` alias onto the canonical ``kg`` value."""
    return "kg" if graph == "neo4j" else graph


# A stable categorical palette (Tableau10-ish) assigned to labels in
# first-seen/alphabetical order so colors are consistent across requests.
_PALETTE = [
    "#4e79a7",
    "#f28e2b",
    "#e15759",
    "#76b7b2",
    "#59a14f",
    "#edc948",
    "#b07aa1",
    "#ff9da7",
    "#9c755f",
    "#bab0ac",
]

# Display-name property candidates, in priority order.
_NAME_PROPS = ("name", "title", "id", "path", "key", "label")

# Properties considered "key" for the compact node summary returned by
# /api/nodes and /api/expand (kept small -- full properties live behind the
# node's own detail, but we return everything we have since payloads are tiny).
_MAX_PROPS = 12


def _resolve_memgraph_uri(env: Mapping[str, str] | None = None) -> str:
    """Discover the current Memgraph MAIN via the HA controller, falling
    back to the first URI in MEMGRAPH_URIS, then MEMGRAPH_URI."""
    env_vars = os.environ if env is None else env
    controller_url = env_vars.get("MEMGRAPH_HA_CONTROLLER_URL")
    if controller_url:
        try:
            resp = httpx.get(f"{controller_url.rstrip('/')}/main", timeout=3.0)
            resp.raise_for_status()
            data = resp.json()
            uri = data.get("uri")
            if uri:
                return str(uri)
        except httpx.HTTPError, ValueError:
            pass
    uris_env = env_vars.get("MEMGRAPH_URIS")
    if uris_env:
        first = uris_env.split(",")[0].strip()
        if first:
            return first
    uri = env_vars.get("MEMGRAPH_URI")
    if not uri:
        # No literal fallback (RUL-003). `bolt://localhost:7687` was worse
        # than useless here: nothing in this project runs Memgraph on
        # localhost (the graphs are Incus containers on 10.53.1.x), so the
        # default could only ever turn a missing-config bug into a confusing
        # connection error against a host that was never the target.
        raise RuntimeError(
            "cannot resolve a Memgraph URI: none of MEMGRAPH_HA_CONTROLLER_URL, "
            "MEMGRAPH_URIS or MEMGRAPH_URI is set in the environment"
        )
    return uri


def _get_driver(graph: GraphKind) -> Any:
    if _normalize_graph_kind(graph) == "kg":
        return create_graph()
    return create_graph(uri=_resolve_memgraph_uri(), auth=NO_AUTH)


def _open_session(driver: Any, graph: GraphKind) -> Any:
    """Open a session bound to the database *graph* actually names.

    Since the 2026-07-26 cutover both graphs live on the ONE orchgraph
    cluster, so the URI no longer distinguishes them — the ``database=``
    argument does. A bare ``driver.session()`` lands on the cluster default
    (the shared semantic graph), which is how this browser silently served
    ``Chunk``/``ADR``/``Page`` counts when asked for the operational KG.
    ``?graph=kg`` (or its deprecated ``?graph=neo4j`` alias) therefore has to
    name ``KG_DATABASE`` explicitly; ``?graph=memgraph`` keeps the default,
    which IS the semantic graph.
    """
    if _normalize_graph_kind(graph) == "kg":
        return driver.session(database=resolve_kg_database())
    return driver.session()


def _known_labels(driver: Any, graph: GraphKind) -> set[str]:
    """The set of node labels actually present in the database.

    Used to validate any client-supplied label BEFORE it is interpolated
    into a Cypher query string (labels cannot be parameterized in Cypher).
    """
    with _open_session(driver, graph) as session:
        rows = session.run("MATCH (n) UNWIND labels(n) AS label RETURN DISTINCT label")
        return {row["label"] for row in rows}


def _display_name(props: dict[str, Any], node_id: int, labels: list[str]) -> str:
    for key in _NAME_PROPS:
        val = props.get(key)
        if isinstance(val, str) and val:
            return val
    label = labels[0] if labels else "Node"
    return f"{label}#{node_id}"


def _jsonable(value: Any) -> Any:
    """Coerce a Bolt property value into something the JSON serializer accepts.

    The driver hands back native temporal/spatial objects (``neo4j.time.DateTime``,
    ``Date``, ``Time``, ``Duration``, ``neo4j.spatial.Point``) for temporal and
    point properties. Pydantic cannot serialize those and raises
    ``PydanticSerializationError: Unable to serialize unknown type`` — so any
    node carrying a timestamp used to blow up ``/api/nodes`` and ``/api/expand``
    with a 500. This browser is a read-only viewer: the ISO string (or the
    object's own repr for the exotic types) is exactly what should be shown.
    """
    if isinstance(value, (str, int, float, bool)) or value is None:
        return value
    if isinstance(value, (bytes, bytearray)):
        return value.hex()
    if isinstance(value, (list, tuple)):
        return [_jsonable(v) for v in value]
    if isinstance(value, dict):
        return {str(k): _jsonable(v) for k, v in value.items()}
    for method in ("isoformat", "iso_format"):
        fn = getattr(value, method, None)
        if callable(fn):
            return fn()
    return str(value)


def _trim_props(props: dict[str, Any]) -> dict[str, Any]:
    trimmed = {}
    for i, (k, v) in enumerate(props.items()):
        if i >= _MAX_PROPS:
            break
        trimmed[k] = _jsonable(v)
    return trimmed


class LabelMeta(BaseModel):
    label: str
    count: int
    color: str


class RelMeta(BaseModel):
    type: str
    count: int


class MetaResponse(BaseModel):
    labels: list[LabelMeta]
    relationships: list[RelMeta]


class NodeSummary(BaseModel):
    id: int
    labels: list[str]
    name: str
    properties: dict[str, Any]


class EdgeSummary(BaseModel):
    id: int
    source: int
    target: int
    type: str


class ExpandResponse(BaseModel):
    nodes: list[NodeSummary]
    edges: list[EdgeSummary]
    truncated: bool


app = FastAPI(
    title="Graph Browser", description="Interactive graph explorer for Memgraph/Neo4j"
)
if _STATIC_DIR.is_dir():
    app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")


@app.get("/", response_class=HTMLResponse)
def index() -> str:
    """Serve the self-contained graph-browser HTML app."""
    if not _HTML_PATH.is_file():
        raise HTTPException(status_code=500, detail=f"missing {_HTML_PATH}")
    return _HTML_PATH.read_text(encoding="utf-8")


@app.get("/api/meta", response_model=MetaResponse)
def api_meta(
    graph: GraphKind = Query(
        "memgraph",
        description="'memgraph' or 'kg' ('neo4j' accepted as a deprecated alias for 'kg')",
    ),
) -> MetaResponse:
    """Node labels with counts + assigned colors, relationship types with counts."""
    driver = _get_driver(graph)
    try:
        with _open_session(driver, graph) as session:
            label_rows = list(
                session.run(
                    "MATCH (n) UNWIND labels(n) AS label "
                    "RETURN label, count(*) AS count ORDER BY label"
                )
            )
            rel_rows = list(
                session.run(
                    "MATCH ()-[r]->() RETURN type(r) AS type, count(*) AS count ORDER BY type"
                )
            )
    finally:
        driver.close()

    labels = [
        LabelMeta(
            label=row["label"], count=row["count"], color=_PALETTE[i % len(_PALETTE)]
        )
        for i, row in enumerate(label_rows)
    ]
    relationships = [RelMeta(type=row["type"], count=row["count"]) for row in rel_rows]
    return MetaResponse(labels=labels, relationships=relationships)


@app.get("/api/nodes", response_model=list[NodeSummary])
def api_nodes(
    label: str = Query(..., description="Node label to seed from"),
    limit: int = Query(50, ge=1, le=500),
    q: str | None = Query(None, description="Substring filter on the display name"),
    graph: GraphKind = Query(
        "memgraph",
        description="'memgraph' or 'kg' ('neo4j' accepted as a deprecated alias for 'kg')",
    ),
) -> list[NodeSummary]:
    """Seed nodes for a given label, optionally filtered by a name substring."""
    driver = _get_driver(graph)
    try:
        known = _known_labels(driver, graph)
        if label not in known:
            raise HTTPException(status_code=400, detail=f"unknown label: {label!r}")
        with _open_session(driver, graph) as session:
            cypher = f"MATCH (n:`{label}`) "
            params: dict[str, Any] = {"limit": limit}
            if q:
                # `toString` REJECTS list values outright — it accepts only
                # scalars/temporals — so filtering a label whose nodes carry
                # any list property (every chunk label has `embedding`, a
                # list of floats) raised a ClientError and returned a 500
                # instead of a result set. Restricting the scan to string
                # properties is also the only thing a substring filter can
                # meaningfully match: an embedding vector rendered as text is
                # not something a user searches for.
                # `valueType()`, not the `IS :: STRING` type predicate — the
                # latter is not parsed by this Memgraph build (verified live
                # 2026-07-26).
                cypher += (
                    "WHERE any(prop IN keys(n) WHERE "
                    "valueType(n[prop]) = 'STRING' AND "
                    "toLower(n[prop]) CONTAINS toLower($q)) "
                )
                params["q"] = q
            cypher += "RETURN id(n) AS id, labels(n) AS labels, properties(n) AS props LIMIT $limit"
            rows = list(session.run(cypher, params))
    finally:
        driver.close()

    return [
        NodeSummary(
            id=row["id"],
            labels=row["labels"],
            name=_display_name(row["props"], row["id"], row["labels"]),
            properties=_trim_props(row["props"]),
        )
        for row in rows
    ]


@app.get("/api/expand/{node_id}", response_model=ExpandResponse)
def api_expand(
    node_id: int,
    limit: int = Query(100, ge=1, le=500),
    graph: GraphKind = Query(
        "memgraph",
        description="'memgraph' or 'kg' ('neo4j' accepted as a deprecated alias for 'kg')",
    ),
) -> ExpandResponse:
    """Neighbors + relationships of a node, capped at ``limit`` edges."""
    driver = _get_driver(graph)
    try:
        with _open_session(driver, graph) as session:
            exists_row = session.run(
                "MATCH (n) WHERE id(n) = $id RETURN n IS NOT NULL AS exists",
                {"id": node_id},
            ).single()
            if exists_row is None:
                raise HTTPException(status_code=404, detail=f"node {node_id} not found")

            rows = list(
                session.run(
                    "MATCH (n)-[r]-(m) WHERE id(n) = $id "
                    "RETURN id(r) AS rid, type(r) AS rtype, "
                    "startNode(r) = n AS outgoing, "
                    "id(m) AS mid, labels(m) AS mlabels, properties(m) AS mprops "
                    "LIMIT $limit",
                    {"id": node_id, "limit": limit + 1},
                )
            )
            total_row = session.run(
                "MATCH (n)-[r]-() WHERE id(n) = $id RETURN count(r) AS total",
                {"id": node_id},
            ).single()
    finally:
        driver.close()

    total = total_row["total"] if total_row is not None else 0
    truncated = total > limit
    rows = rows[:limit]

    seen_nodes: dict[int, NodeSummary] = {}
    edges: list[EdgeSummary] = []
    for row in rows:
        mid = row["mid"]
        if mid not in seen_nodes:
            seen_nodes[mid] = NodeSummary(
                id=mid,
                labels=row["mlabels"],
                name=_display_name(row["mprops"], mid, row["mlabels"]),
                properties=_trim_props(row["mprops"]),
            )
        source, target = (node_id, mid) if row["outgoing"] else (mid, node_id)
        edges.append(
            EdgeSummary(id=row["rid"], source=source, target=target, type=row["rtype"])
        )

    return ExpandResponse(
        nodes=list(seen_nodes.values()), edges=edges, truncated=truncated
    )


def main() -> None:
    import uvicorn

    parser = argparse.ArgumentParser(
        description="Launch the interactive graph browser."
    )
    parser.add_argument("--host", default="127.0.0.1")
    parser.add_argument("--port", type=int, default=8090)
    args = parser.parse_args()
    # http="h11": "auto" imports httptools, which re-enables the GIL
    # process-wide (SPEC-023). Same pin as scripts/ast_daemon.py.
    uvicorn.run(app, host=args.host, port=args.port, http="h11")


if __name__ == "__main__":
    main()