From f4478c323bc7bde6d65342db856183f08757c350 Mon Sep 17 00:00:00 2001 From: yuxizhe Date: Fri, 16 Oct 2026 13:59:11 +0800 Subject: [PATCH 1/2] feat: add web admin console for managing hypergraph databases Add a browser-based admin console (`db.admin()`) on top of the existing viewer server: - Overview dashboard: counts, degree / hyperedge size distributions (log scale, geometric buckets for long tails), top vertices, entity types and attribute keys - Vertex and hyperedge tables with search, filtering, sorting and paging - Create, edit (JSON attribute editor) and delete vertices and hyperedges - HIF import / export and explicit save to the .hgdb file - Embedded graph viewer; the viewer now accepts ?vertex= - Chinese / English UI switch (remembered per browser) - JSON REST API under /api/admin Also make the viewer's /api/graph resolve non-string vertex ids (e.g. ints), which previously never matched the string query parameter, and hide the `cluster` attribute in the viewer's detail panel. Co-Authored-By: Claude Opus 5.5 --- README.md | 27 + docs/assets/admin/overview.jpg | Bin 0 -> 87297 bytes hyperdb/admin.py | 501 ++++++++++ hyperdb/base.py | 17 + hyperdb/draw.py | 6 +- hyperdb/templates/admin.html | 1104 ++++++++++++++++++++++ hyperdb/templates/hypergraph_viewer.html | 23 +- tests/test_admin.py | 149 +++ 8 files changed, 1822 insertions(+), 5 deletions(-) create mode 100644 docs/assets/admin/overview.jpg create mode 100644 hyperdb/admin.py create mode 100644 hyperdb/templates/admin.html create mode 100644 tests/test_admin.py diff --git a/README.md b/README.md index eb46e8e..bbaaefd 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ Alice ←→ Bob, Bob ←→ Charlie, Alice ←→ Charlie (3 separate edges) | 🔗 **Native Hypergraph Support** | True multi-way relationships, not decomposed binary edges | | 💾 **Persistence** | Save/load with pickle, JSON, or custom formats | | 🎨 **Interactive Visualization** | Built-in web-based visualization with D3.js | +| 🛠️ **Admin Console** | Web UI to browse, edit and manage your database | | 🔍 **Rich Queries** | Neighbor queries, degree calculations, community detection | | 📊 **Flexible Attributes** | Rich metadata for both vertices and hyperedges | | 🛠️ **Developer Friendly** | Modern Python with type hints, comprehensive docs | @@ -138,6 +139,7 @@ This performance makes **Hypergraph-DB** a great choice for applications requiri | **🎯 Flexible Structure** | Support for complex hypergraphs with vertices and multi-way connections | | **⚡ High Performance** | Linear scaling with optimized operations for large datasets | | **🎨 Interactive Viz** | Beautiful web-based visualization with real-time interaction | +| **🛠️ Admin Console** | Browse, search, edit and manage the database from your browser | | **💾 Persistent Storage** | Efficient save/load with data integrity guarantees | | **🔍 Smart Queries** | Fast neighbor discovery and relationship exploration | | **🛠️ Developer Friendly** | Modern Python with type hints and comprehensive documentation | @@ -150,6 +152,7 @@ This performance makes **Hypergraph-DB** a great choice for applications requiri - **🔗 Relationship Queries**: Efficient neighbor and incident edge discovery - **📊 Data Persistence**: Reliable serialization with pickle support - **🎨 Visual Exploration**: Interactive web interface for graph visualization +- **🛠️ Web Administration**: Admin console with statistics, CRUD editing and HIF import/export - **⚙️ Extensible Design**: Built on dataclasses for easy customization --- @@ -338,6 +341,30 @@ hg.draw() +#### **8. 🛠️ Admin Console** + +Manage your database from the browser with the built-in admin console: + +```python +hg.admin() # http://127.0.0.1:8900 +``` + +**✨ Admin Console Features:** +- 📊 **Dashboard**: Key statistics, degree and hyperedge size distributions, top vertices, entity types and attribute keys +- 🔍 **Browse & Search**: Paginated vertex and hyperedge tables with search, filtering and sorting +- ✏️ **Full CRUD**: Create, edit (JSON attribute editor), and delete vertices and hyperedges +- 📦 **HIF Import / Export**: Exchange data with other hypergraph tools +- 🎨 **Built-in Graph Viewer**: Jump from any vertex straight to its visualization +- 💾 **Safe Editing**: Changes stay in memory until you click **Save** (or press Ctrl/⌘+S) + +
+ +![Hypergraph DB Admin Console](docs/assets/admin/overview.jpg) + +*Admin console overview of a 25K-vertex neurology knowledge hypergraph* + +
+ --- ## 📄 License diff --git a/docs/assets/admin/overview.jpg b/docs/assets/admin/overview.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d80403d18e0e6ebf46ffb0ee3c9e5ad398213d33 GIT binary patch literal 87297 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 z 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 I>%R^CA9j!sQUCw| literal 0 HcmV?d00001 diff --git a/hyperdb/admin.py b/hyperdb/admin.py new file mode 100644 index 0000000..e733ef8 --- /dev/null +++ b/hyperdb/admin.py @@ -0,0 +1,501 @@ +""" +Web-based management console for HypergraphDB. + +Provides a local HTTP server with a REST API for browsing, creating, editing and +deleting vertices / hyperedges, viewing statistics, saving the database and +importing / exporting HIF files. The existing hypergraph viewer is served at +``/viewer`` from the same server. + +Usage:: + + db.admin() +""" + +import json +import socketserver +import threading +import time +import webbrowser +from collections import Counter +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import parse_qs, urlparse + +from .base import BaseHypergraphDB +from .draw import HypergraphAPIHandler + +MAX_BODY_SIZE = 200 * 1024 * 1024 # 200 MB, enough for large HIF imports + + +class AdminAPIError(Exception): + """Error that is reported to the client as a JSON error response.""" + + def __init__(self, message: str, status: int = 400): + super().__init__(message) + self.message = message + self.status = status + + +def _json_safe(value: Any) -> Any: + """Convert a value into something json.dumps can serialize.""" + return json.loads(json.dumps(value, ensure_ascii=False, default=str)) + + +def _preview(data: Any, limit: int = 120) -> str: + text = json.dumps(data, ensure_ascii=False, default=str) + return text if len(text) <= limit else text[:limit] + "..." + + +class AdminState: + """Mutable state shared across requests (the handler is re-created for every request).""" + + def __init__(self, hypergraph_db: BaseHypergraphDB): + self.hypergraph_db = hypergraph_db + self.dirty = False + self.last_saved: Optional[float] = None + + +class HypergraphAdminHandler(HypergraphAPIHandler): + """HTTP request handler for the admin console. + + Admin endpoints live under ``/api/admin``. Every other GET path falls back to + the viewer handler, so ``/api/vertices`` and ``/api/graph`` keep working for + the embedded visualization page at ``/viewer``. + """ + + def __init__(self, state: AdminState, *args, **kwargs): + self.state = state + super().__init__(state.hypergraph_db, *args, **kwargs) + + # ------------------------------------------------------------------ routing + + def do_GET(self): + parsed = urlparse(self.path) + path = parsed.path + params = {k: v[0] for k, v in parse_qs(parsed.query).items()} + + if path in ("/", "/admin", "/index.html"): + self._send_html(self._read_template("admin.html")) + elif path == "/viewer": + self._send_html(self._get_html_template()) + elif path == "/api/admin/hif": + self._handle(lambda: self.hypergraph_db.to_hif(), download="hypergraph.hif.json") + elif path.startswith("/api/admin/"): + routes = { + "/api/admin/info": self._info, + "/api/admin/stats": self._stats, + "/api/admin/vertices": lambda: self._list_vertices(params), + "/api/admin/edges": lambda: self._list_edges(params), + } + if path in routes: + self._handle(routes[path]) + else: + self._send_json({"error": f"Unknown endpoint: {path}"}, 404) + else: + super().do_GET() + + def do_POST(self): + path = urlparse(self.path).path + routes = { + "/api/admin/vertex/get": self._get_vertex, + "/api/admin/vertex/upsert": self._upsert_vertex, + "/api/admin/vertex/delete": self._delete_vertex, + "/api/admin/edge/get": self._get_edge, + "/api/admin/edge/upsert": self._upsert_edge, + "/api/admin/edge/delete": self._delete_edge, + "/api/admin/save": self._save, + "/api/admin/hif": self._import_hif, + } + if path not in routes: + self._send_json({"error": f"Unknown endpoint: {path}"}, 404) + return + self._handle(lambda: routes[path](self._read_body())) + + # ------------------------------------------------------------------ helpers + + def _handle(self, fn, download: Optional[str] = None): + try: + result = fn() + except AdminAPIError as e: + self._send_json({"error": e.message}, e.status) + return + except AssertionError as e: + self._send_json({"error": str(e) or "Invalid request"}, 400) + return + except Exception as e: # pragma: no cover - defensive + self._send_json({"error": f"{type(e).__name__}: {e}"}, 500) + return + self._send_json(result, 200, download=download) + + def _send_json(self, payload: Any, status: int = 200, download: Optional[str] = None): + body = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + if download: + self.send_header("Content-Disposition", f'attachment; filename="{download}"') + self.end_headers() + self.wfile.write(body) + + def _send_html(self, html: str): + body = html.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _read_template(self, name: str) -> str: + return (Path(__file__).parent / "templates" / name).read_text(encoding="utf-8") + + def _read_body(self) -> Dict[str, Any]: + length = int(self.headers.get("Content-Length") or 0) + if length > MAX_BODY_SIZE: + raise AdminAPIError("Request body too large", 413) + raw = self.rfile.read(length) if length else b"{}" + try: + body = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as e: + raise AdminAPIError(f"Invalid JSON body: {e}") + if not isinstance(body, dict): + raise AdminAPIError("Request body must be a JSON object") + return body + + def _resolve_v(self, raw: Any) -> Any: + """Map a vertex id received as JSON back to the actual id stored in the DB. + + JSON-native ids (str / int / float) match directly; other hashable ids + (e.g. tuples) are sent to the client as strings and matched by ``str()``. + """ + hg = self.hypergraph_db + if isinstance(raw, list): + raw = tuple(raw) + try: + if hg.has_v(raw): + return raw + except (AssertionError, TypeError): + pass + for v_id in hg.all_v: + if str(v_id) == str(raw): + return v_id + raise AdminAPIError(f"Vertex {raw!r} does not exist", 404) + + def _resolve_e(self, raw: Any) -> Tuple: + if not isinstance(raw, list) or len(raw) < 2: + raise AdminAPIError("A hyperedge must be a list of at least 2 vertex ids") + return self.hypergraph_db.encode_e([self._resolve_v(v) for v in raw]) + + @staticmethod + def _require_dict(body: Dict[str, Any], key: str) -> Dict[str, Any]: + data = body.get(key, {}) + if data is None: + data = {} + if not isinstance(data, dict): + raise AdminAPIError(f"'{key}' must be a JSON object") + return data + + def _mark_dirty(self): + self.state.dirty = True + + def _edge_row(self, e_tuple: Tuple) -> Dict[str, Any]: + data = self.hypergraph_db.e(e_tuple, {}) + return { + "vertices": _json_safe(list(e_tuple)), + "size": len(e_tuple), + "preview": _preview(data), + } + + @staticmethod + def _paginate(rows: List[Any], params: Dict[str, str]) -> Dict[str, Any]: + try: + page = max(1, int(params.get("page", 1))) + page_size = min(500, max(1, int(params.get("page_size", 20)))) + except ValueError: + raise AdminAPIError("page and page_size must be integers") + total = len(rows) + start = (page - 1) * page_size + return { + "data": rows[start : start + page_size], + "pagination": { + "page": page, + "page_size": page_size, + "total": total, + "total_pages": max(1, (total + page_size - 1) // page_size), + }, + } + + # ------------------------------------------------------------------ read endpoints + + def _info(self) -> Dict[str, Any]: + hg = self.hypergraph_db + storage = Path(hg.storage_file) + return { + "storage_file": str(storage.resolve()), + "file_exists": storage.exists(), + "file_size": storage.stat().st_size if storage.exists() else None, + "num_v": hg.num_v, + "num_e": hg.num_e, + "dirty": self.state.dirty, + "last_saved": self.state.last_saved, + } + + def _stats(self) -> Dict[str, Any]: + hg = self.hypergraph_db + degrees = [hg.degree_v(v) for v in hg.all_v] + sizes = [len(e) for e in hg.all_e] + v_keys: Counter = Counter() + e_keys: Counter = Counter() + types: Counter = Counter() + for v in hg.all_v: + data = hg.v(v, {}) or {} + v_keys.update(data.keys()) + if data.get("entity_type"): + types[str(data["entity_type"])] += 1 + for e in hg.all_e: + e_keys.update((hg.e(e, {}) or {}).keys()) + + top = sorted(hg.all_v, key=lambda v: hg.degree_v(v), reverse=True)[:10] + return { + "num_v": hg.num_v, + "num_e": hg.num_e, + "isolated_v": sum(1 for d in degrees if d == 0), + "avg_degree": round(sum(degrees) / len(degrees), 3) if degrees else 0, + "max_degree": max(degrees, default=0), + "avg_edge_size": round(sum(sizes) / len(sizes), 3) if sizes else 0, + "max_edge_size": max(sizes, default=0), + "degree_hist": sorted(Counter(degrees).items()), + "edge_size_hist": sorted(Counter(sizes).items()), + "entity_types": types.most_common(12), + "vertex_keys": v_keys.most_common(20), + "edge_keys": e_keys.most_common(20), + "top_vertices": [{"id": _json_safe(v), "degree": hg.degree_v(v)} for v in top], + } + + def _list_vertices(self, params: Dict[str, str]) -> Dict[str, Any]: + hg = self.hypergraph_db + search = params.get("search", "").strip().lower() + entity_type = params.get("entity_type", "") + sort_by = params.get("sort_by", "degree") + desc = params.get("sort_order", "desc") == "desc" + + rows = [] + for v_id in hg.all_v: + data = hg.v(v_id, {}) or {} + if entity_type and str(data.get("entity_type", "")) != entity_type: + continue + id_match = bool(search) and search in str(v_id).lower() + if search and not id_match and search not in _preview(data, 10**9).lower(): + continue + rows.append( + { + "id": _json_safe(v_id), + "degree": hg.degree_v(v_id), + "entity_type": str(data.get("entity_type", "")), + "preview": _preview(data), + "_id_match": id_match, + } + ) + if sort_by == "id": + rows.sort(key=lambda r: str(r["id"]), reverse=desc) + else: + rows.sort(key=lambda r: (r["degree"], str(r["id"])), reverse=desc) + # Stable sort: when searching, vertices whose id matches come before attribute-only matches + rows.sort(key=lambda r: not r.pop("_id_match")) + return self._paginate(rows, params) + + def _list_edges(self, params: Dict[str, str]) -> Dict[str, Any]: + hg = self.hypergraph_db + search = params.get("search", "").strip().lower() + vertex = params.get("vertex") + sort_by = params.get("sort_by", "size") + desc = params.get("sort_order", "desc") == "desc" + + edges = hg.all_e + if vertex: + edges = hg.nbr_e_of_v(self._resolve_v(json.loads(vertex))) + rows = [] + for e in edges: + if search: + haystack = " ".join(str(v) for v in e) + " " + _preview(hg.e(e, {}), 10**9) + if search not in haystack.lower(): + continue + rows.append(self._edge_row(e)) + if sort_by == "vertices": + rows.sort(key=lambda r: str(r["vertices"]), reverse=desc) + else: + rows.sort(key=lambda r: (r["size"], str(r["vertices"])), reverse=desc) + return self._paginate(rows, params) + + def _get_vertex(self, body: Dict[str, Any]) -> Dict[str, Any]: + hg = self.hypergraph_db + v_id = self._resolve_v(body.get("id")) + edges = sorted(hg.nbr_e_of_v(v_id), key=lambda e: (-len(e), str(e))) + return { + "id": _json_safe(v_id), + "data": _json_safe(hg.v(v_id, {})), + "degree": hg.degree_v(v_id), + "neighbors": len(hg.nbr_v(v_id, exclude_self=False) - {v_id}), # nbr_v() raises on isolated vertices + "edges": [self._edge_row(e) for e in edges[:200]], + "edges_truncated": len(edges) > 200, + } + + def _get_edge(self, body: Dict[str, Any]) -> Dict[str, Any]: + hg = self.hypergraph_db + e_tuple = self._resolve_e(body.get("vertices")) + if not hg.has_e(e_tuple): + raise AdminAPIError("Hyperedge does not exist", 404) + return { + "vertices": [{"id": _json_safe(v), "degree": hg.degree_v(v)} for v in e_tuple], + "data": _json_safe(hg.e(e_tuple, {})), + } + + # ------------------------------------------------------------------ write endpoints + + def _upsert_vertex(self, body: Dict[str, Any]) -> Dict[str, Any]: + """Create (``create: true``) or update a vertex. + + ``replace: true`` overwrites the attribute dict instead of merging into it, + which is how the editor removes attributes. + """ + hg = self.hypergraph_db + data = self._require_dict(body, "data") + if body.get("create"): + v_id = body.get("id") + if not isinstance(v_id, (str, int, float)) or isinstance(v_id, bool) or v_id == "": + raise AdminAPIError("Vertex id must be a non-empty string or a number") + if hg.has_v(v_id): + raise AdminAPIError(f"Vertex {v_id!r} already exists", 409) + hg.add_v(v_id, data) + else: + v_id = self._resolve_v(body.get("id")) + if body.get("replace"): + hg.v(v_id).clear() + hg.update_v(v_id, data) + self._mark_dirty() + return self._get_vertex({"id": _json_safe(v_id)}) + + def _delete_vertex(self, body: Dict[str, Any]) -> Dict[str, Any]: + self.hypergraph_db.remove_v(self._resolve_v(body.get("id"))) + self._mark_dirty() + return {"ok": True} + + def _upsert_edge(self, body: Dict[str, Any]) -> Dict[str, Any]: + hg = self.hypergraph_db + data = self._require_dict(body, "data") + e_tuple = self._resolve_e(body.get("vertices")) + exists = hg.has_e(e_tuple) + if body.get("create"): + if exists: + raise AdminAPIError("Hyperedge already exists", 409) + hg.add_e(e_tuple, data) + else: + if not exists: + raise AdminAPIError("Hyperedge does not exist", 404) + if body.get("replace"): + hg.e(e_tuple).clear() + hg.update_e(e_tuple, data) + self._mark_dirty() + return self._get_edge({"vertices": _json_safe(list(e_tuple))}) + + def _delete_edge(self, body: Dict[str, Any]) -> Dict[str, Any]: + hg = self.hypergraph_db + e_tuple = self._resolve_e(body.get("vertices")) + if not hg.has_e(e_tuple): + raise AdminAPIError("Hyperedge does not exist", 404) + hg.remove_e(e_tuple) + self._mark_dirty() + return {"ok": True} + + def _save(self, body: Dict[str, Any]) -> Dict[str, Any]: + hg = self.hypergraph_db + if not hg.save(hg.storage_file): + raise AdminAPIError(f"Failed to save to {hg.storage_file}", 500) + self.state.dirty = False + self.state.last_saved = time.time() + return self._info() + + def _import_hif(self, body: Dict[str, Any]) -> Dict[str, Any]: + if "incidences" not in body: + raise AdminAPIError("Invalid HIF data: missing 'incidences'") + if not self.hypergraph_db.from_hif(body): + raise AdminAPIError("Failed to import HIF data") + self._mark_dirty() + return self._info() + + +class _AdminTCPServer(socketserver.TCPServer): + allow_reuse_address = True + + +class HypergraphAdmin: + """Management console server for a hypergraph database.""" + + def __init__(self, hypergraph_db: BaseHypergraphDB, host: str = "127.0.0.1", port: int = 8900): + self.hypergraph_db = hypergraph_db + self.host = host + self.port = port + self.state = AdminState(hypergraph_db) + self.httpd: Optional[socketserver.TCPServer] = None + + @property + def url(self) -> str: + return f"http://{self.host}:{self.port}" + + def start_server(self, open_browser: bool = True) -> threading.Thread: + def handler(*args, **kwargs): + return HypergraphAdminHandler(self.state, *args, **kwargs) + + # Requests are served one at a time on purpose: HypergraphDB is not thread-safe. + self.httpd = _AdminTCPServer((self.host, self.port), handler) + self.port = self.httpd.server_address[1] + thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) + thread.start() + + print(f"🛠 Hypergraph DB admin started: {self.url}") + if open_browser: + webbrowser.open(self.url) + return thread + + def stop_server(self): + if self.httpd is not None: + self.httpd.shutdown() + self.httpd.server_close() + self.httpd = None + + +def run_admin( + hypergraph_db: BaseHypergraphDB, + host: str = "127.0.0.1", + port: int = 8900, + open_browser: bool = True, + blocking: bool = True, +) -> HypergraphAdmin: + """Start the admin console for ``hypergraph_db``. + + Args: + hypergraph_db: HypergraphDB instance to manage + host: Interface to bind; keep the default unless you trust your network, + the console has no authentication + port: Server port (0 picks a free port) + open_browser: Whether to open the console in a browser + blocking: Whether to block until Ctrl+C. If False, returns immediately. + + Returns: + HypergraphAdmin instance + """ + admin = HypergraphAdmin(hypergraph_db, host=host, port=port) + thread = admin.start_server(open_browser=open_browser) + if not blocking: + return admin + + print("⌨️ Press Ctrl+C to stop server") + try: + while thread.is_alive(): + time.sleep(0.2) + except KeyboardInterrupt: + pass + if admin.state.dirty: + print("⚠️ Unsaved changes were discarded (use the Save button to persist them)") + print("\n🛑 Server stopped") + admin.stop_server() + return admin diff --git a/hyperdb/base.py b/hyperdb/base.py index b7e75df..1c6c656 100644 --- a/hyperdb/base.py +++ b/hyperdb/base.py @@ -300,3 +300,20 @@ def draw(self, port: int = 8080, open_browser: bool = True, blocking: bool = Tru from .draw import draw_hypergraph return draw_hypergraph(hypergraph_db=self, port=port, open_browser=open_browser, blocking=blocking) + + def admin(self, port: int = 8900, open_browser: bool = True, blocking: bool = True, host: str = "127.0.0.1"): + """ + Open the web admin console for the current HyperDB instance + + Args: + ``port``: Server port number, defaults to 8900 + ``open_browser``: Whether to automatically open the browser, defaults to True + ``blocking``: Whether to block the main thread, defaults to True. Set to False for non-blocking mode. + ``host``: Interface to bind, defaults to 127.0.0.1. The console has no authentication. + + Returns: + HypergraphAdmin instance + """ + from .admin import run_admin + + return run_admin(hypergraph_db=self, host=host, port=port, open_browser=open_browser, blocking=blocking) diff --git a/hyperdb/draw.py b/hyperdb/draw.py index be53a4c..bbc5394 100644 --- a/hyperdb/draw.py +++ b/hyperdb/draw.py @@ -170,7 +170,11 @@ def _get_graph_data(self, vertex_id: str) -> Dict[str, Any]: hg = self.hypergraph_db if not hg.has_v(vertex_id): - return {"error": f"Vertex {vertex_id} not found"} + # Query params are always strings; match non-string ids (e.g. ints) by str() + match = next((v for v in hg.all_v if str(v) == vertex_id), None) + if match is None: + return {"error": f"Vertex {vertex_id} not found"} + vertex_id = match # Get all neighbor hyperedges of the vertex neighbor_edges = hg.nbr_e_of_v(vertex_id) diff --git a/hyperdb/templates/admin.html b/hyperdb/templates/admin.html new file mode 100644 index 0000000..1321671 --- /dev/null +++ b/hyperdb/templates/admin.html @@ -0,0 +1,1104 @@ + + + + + + Hypergraph DB Admin + + + + + + + + +

+ + + + diff --git a/hyperdb/templates/hypergraph_viewer.html b/hyperdb/templates/hypergraph_viewer.html index 2c0e200..5c269cd 100644 --- a/hyperdb/templates/hypergraph_viewer.html +++ b/hyperdb/templates/hypergraph_viewer.html @@ -205,6 +205,11 @@ total_pages: 0, }); const [selectedVertex, setSelectedVertex] = useState(""); + // Vertex requested via ?vertex= (e.g. from the admin console); wins over the + // default "select first vertex" until the user picks something else. + const initialVertexRef = useRef( + new URLSearchParams(window.location.search).get("vertex") || "" + ); const [graphData, setGraphData] = useState(null); const [loading, setLoading] = useState(false); const [loadingVertices, setLoadingVertices] = useState(false); @@ -240,8 +245,10 @@ setVertices(result.data); setPagination(result.pagination); - // If no vertex selected and data exists, select the first one - if (result.data.length > 0) { + // Select the requested vertex, otherwise the first one + if (initialVertexRef.current) { + setSelectedVertex(initialVertexRef.current); + } else if (result.data.length > 0) { setSelectedVertex(result.data[0].id); } } catch (err) { @@ -282,6 +289,7 @@ // Load when page changes const handlePageChange = (newPage) => { + initialVertexRef.current = ""; loadVertices(newPage, searchTerm, sortBy, sortOrder); }; @@ -316,6 +324,7 @@ "data", "weight", "source_id", + "cluster", ]); // Convert data to G6 Graph format @@ -638,7 +647,10 @@

type="text" placeholder="Search vertex ID, type or description..." value={searchTerm} - onChange={(e) => setSearchTerm(e.target.value)} + onChange={(e) => { + initialVertexRef.current = ""; + setSearchTerm(e.target.value); + }} className="w-full px-4 py-2 pl-10 pr-4 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all duration-200" />
@@ -725,7 +737,10 @@

? "bg-primary-50 border-l-4 border-l-primary-500 shadow-md" : "" }`} - onClick={() => setSelectedVertex(vertex.id)} + onClick={() => { + initialVertexRef.current = ""; + setSelectedVertex(vertex.id); + }} >
{vertex.id} diff --git a/tests/test_admin.py b/tests/test_admin.py new file mode 100644 index 0000000..a078cbb --- /dev/null +++ b/tests/test_admin.py @@ -0,0 +1,149 @@ +import json +import urllib.error +import urllib.request + +import pytest + +from hyperdb import HypergraphDB +from hyperdb.admin import HypergraphAdmin + + +@pytest.fixture() +def server(tmp_path): + db = HypergraphDB(storage_file=tmp_path / "admin.hgdb") + db.add_v(1, {"name": "Alice", "entity_type": "person"}) + db.add_v(2, {"name": "Bob", "entity_type": "person"}) + db.add_v(3, {"name": "Charlie", "entity_type": "person"}) + db.add_v(4, {"name": "Paper", "entity_type": "doc"}) + db.add_e((1, 2), {"relation": "knows"}) + db.add_e((1, 2, 3, 4), {"relation": "wrote"}) + admin = HypergraphAdmin(db, port=0) + admin.start_server(open_browser=False) + yield admin + admin.stop_server() + + +def call(admin, path, body=None): + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request(admin.url + path, data=data, headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req) as res: + return res.status, json.loads(res.read()) + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read()) + + +def test_pages_served(server): + with urllib.request.urlopen(server.url + "/") as res: + assert b"Hypergraph DB Admin" in res.read() + with urllib.request.urlopen(server.url + "/viewer") as res: + assert b"Hypergraph Visualization" in res.read() + + +def test_info_and_stats(server): + status, info = call(server, "/api/admin/info") + assert status == 200 + assert info["num_v"] == 4 and info["num_e"] == 2 and info["dirty"] is False + + _, stats = call(server, "/api/admin/stats") + assert stats["max_degree"] == 2 + assert stats["edge_size_hist"] == [[2, 1], [4, 1]] + assert stats["entity_types"][0] == ["person", 3] + + +def test_list_vertices_and_edges(server): + _, res = call(server, "/api/admin/vertices?search=bob") + assert [v["id"] for v in res["data"]] == [2] + + _, res = call(server, "/api/admin/vertices?entity_type=doc") + assert [v["id"] for v in res["data"]] == [4] + + _, res = call(server, "/api/admin/vertices?page_size=2&page=2") + assert len(res["data"]) == 2 and res["pagination"]["total_pages"] == 2 + + _, res = call(server, "/api/admin/edges?search=wrote") + assert res["data"][0]["size"] == 4 + + _, res = call(server, "/api/admin/edges?vertex=3") + assert res["pagination"]["total"] == 1 + + +def test_vertex_crud(server): + db = server.hypergraph_db + status, res = call(server, "/api/admin/vertex/upsert", {"create": True, "id": 5, "data": {"name": "Eve"}}) + assert status == 200 and db.v(5) == {"name": "Eve"} + + status, _ = call(server, "/api/admin/vertex/upsert", {"create": True, "id": 5}) + assert status == 409 + + # Merge vs replace + call(server, "/api/admin/vertex/upsert", {"id": 5, "data": {"age": 30}}) + assert db.v(5) == {"name": "Eve", "age": 30} + call(server, "/api/admin/vertex/upsert", {"id": 5, "data": {"age": 31}, "replace": True}) + assert db.v(5) == {"age": 31} + + _, detail = call(server, "/api/admin/vertex/get", {"id": 1}) + assert detail["degree"] == 2 and detail["neighbors"] == 3 + + call(server, "/api/admin/vertex/delete", {"id": 2}) + assert not db.has_v(2) and db.num_e == 1 # (1, 2) dropped below 2 vertices + + status, _ = call(server, "/api/admin/vertex/get", {"id": 99}) + assert status == 404 + assert call(server, "/api/admin/info")[1]["dirty"] is True + + +def test_edge_crud(server): + db = server.hypergraph_db + status, res = call(server, "/api/admin/edge/upsert", {"create": True, "vertices": [3, 4], "data": {"w": 1}}) + assert status == 200 and db.e((3, 4)) == {"w": 1} + + status, _ = call(server, "/api/admin/edge/upsert", {"create": True, "vertices": [4, 3]}) + assert status == 409 + status, _ = call(server, "/api/admin/edge/upsert", {"create": True, "vertices": [1, 42]}) + assert status == 404 + status, _ = call(server, "/api/admin/edge/upsert", {"create": True, "vertices": [1]}) + assert status == 400 + + call(server, "/api/admin/edge/upsert", {"vertices": [3, 4], "data": {"x": 2}, "replace": True}) + assert db.e((3, 4)) == {"x": 2} + + call(server, "/api/admin/edge/delete", {"vertices": [3, 4]}) + assert not db.has_e((3, 4)) + + +def test_save_and_hif_roundtrip(server): + db = server.hypergraph_db + call(server, "/api/admin/vertex/upsert", {"create": True, "id": "new"}) + status, info = call(server, "/api/admin/save", {}) + assert status == 200 and info["dirty"] is False and info["file_exists"] + assert HypergraphDB(storage_file=db.storage_file).has_v("new") + + _, hif = call(server, "/api/admin/hif") + assert "incidences" in hif + + status, _ = call(server, "/api/admin/hif", {"nope": []}) + assert status == 400 + status, info = call( + server, "/api/admin/hif", {"incidences": [{"edge": "e", "node": "a"}, {"edge": "e", "node": "b"}]} + ) + assert status == 200 and info["num_v"] == 2 and info["num_e"] == 1 and info["dirty"] is True + + +def test_bad_requests(server): + assert call(server, "/api/admin/nope")[0] == 404 + assert call(server, "/api/admin/vertex/upsert", {"id": 1, "data": [1, 2]})[0] == 400 + assert call(server, "/api/admin/vertex/upsert", {"create": True, "id": ""})[0] == 400 + + +def test_viewer_graph_accepts_non_string_ids(server): + with urllib.request.urlopen(server.url + "/api/graph?vertex_id=1") as res: + data = json.loads(res.read()) + assert "error" not in data and len(data["edges"]) == 2 + + +def test_vertex_search_ranks_id_matches_first(server): + # "bobby" has degree 0 but matches by id, so it outranks attribute-only matches with higher degree + call(server, "/api/admin/vertex/upsert", {"create": True, "id": "bobby"}) + _, res = call(server, "/api/admin/vertices?search=bob") + assert [v["id"] for v in res["data"]] == ["bobby", 2] From 7134bef5bfeb13326cfbd98e89b2915607b4806c Mon Sep 17 00:00:00 2001 From: yuxizhe Date: Fri, 16 Oct 2026 14:05:53 +0800 Subject: [PATCH 2/2] fix: satisfy mypy for admin handler and HIF calls Annotate HypergraphAPIHandler with BaseHypergraphDB (what the viewer already accepts), and have the admin look up to_hif / from_hif at runtime, returning 501 for database classes without HIF support. Co-Authored-By: Claude Opus 5.5 --- hyperdb/admin.py | 14 ++++++++++++-- hyperdb/draw.py | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/hyperdb/admin.py b/hyperdb/admin.py index e733ef8..aa3b3d7 100644 --- a/hyperdb/admin.py +++ b/hyperdb/admin.py @@ -79,7 +79,7 @@ def do_GET(self): elif path == "/viewer": self._send_html(self._get_html_template()) elif path == "/api/admin/hif": - self._handle(lambda: self.hypergraph_db.to_hif(), download="hypergraph.hif.json") + self._handle(self._export_hif, download="hypergraph.hif.json") elif path.startswith("/api/admin/"): routes = { "/api/admin/info": self._info, @@ -414,10 +414,20 @@ def _save(self, body: Dict[str, Any]) -> Dict[str, Any]: self.state.last_saved = time.time() return self._info() + def _hif_method(self, name: str): + # HIF support lives on HypergraphDB, not on BaseHypergraphDB + method = getattr(self.hypergraph_db, name, None) + if method is None: + raise AdminAPIError(f"{type(self.hypergraph_db).__name__} does not support HIF", 501) + return method + + def _export_hif(self) -> Dict[str, Any]: + return self._hif_method("to_hif")() + def _import_hif(self, body: Dict[str, Any]) -> Dict[str, Any]: if "incidences" not in body: raise AdminAPIError("Invalid HIF data: missing 'incidences'") - if not self.hypergraph_db.from_hif(body): + if not self._hif_method("from_hif")(body): raise AdminAPIError("Failed to import HIF data") self._mark_dirty() return self._info() diff --git a/hyperdb/draw.py b/hyperdb/draw.py index bbc5394..10e8b58 100644 --- a/hyperdb/draw.py +++ b/hyperdb/draw.py @@ -14,7 +14,7 @@ class HypergraphAPIHandler(http.server.BaseHTTPRequestHandler): """HTTP request handler with API endpoints""" - def __init__(self, hypergraph_db: HypergraphDB, *args, **kwargs): + def __init__(self, hypergraph_db: BaseHypergraphDB, *args, **kwargs): self.hypergraph_db = hypergraph_db super().__init__(*args, **kwargs)