From b516eb20eb0a94977a30e8a45a20bb655f825238 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 24 Aug 2026 09:16:34 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20Projects=20enrichment=20=E2=80=94=20?= =?UTF-8?q?pass=20include=5Fdetails,=20cancel=20on=20unmount,=20cache=20sc?= =?UTF-8?q?hemas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lazy-schema fix was ineffective: the UI never sent include_details, so list_projects returned names only, every card lacked totals, and the >1M-node skip compared against undefined — each Projects visit still ran a ~30 s kernel schema scan, and revisits stacked uncancelled sweeps that kept the store busy (the felt slowness). Now: list_projects(include_details:true) carries totals and indexed_at; the enrichment loop is generation-guarded (unmount or refresh abandons it), schemas are cached for the session, auto-fetch is capped at 20 projects of ≤200k nodes, and larger stores keep a card with totals and no chips. After a Projects visit the daemon log shows only sub-second requests. Signed-off-by: Martin Vogel --- graph-ui/src/hooks/useProjects.ts | 58 ++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/graph-ui/src/hooks/useProjects.ts b/graph-ui/src/hooks/useProjects.ts index 12767a036..c4df265a9 100644 --- a/graph-ui/src/hooks/useProjects.ts +++ b/graph-ui/src/hooks/useProjects.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { callTool } from "../api/rpc"; import type { Project, SchemaInfo } from "../lib/types"; @@ -14,36 +14,63 @@ interface UseProjectsResult { refresh: () => void; } +/* Schemas above this size are never auto-fetched: get_graph_schema scans + * every node and edge, which on a multi-million-node index takes ~30 s of + * server time. The card still shows totals from list_projects; only the + * per-label chips are absent. */ +const SCHEMA_LAZY_MAX_NODES = 200_000; +/* And never enrich more than this many cards per visit. */ +const SCHEMA_LAZY_MAX_PROJECTS = 20; + +/* Session-scoped schema cache: revisiting the Projects tab must not rescan + * stores the server already walked for us once. */ +const schemaCache = new Map(); + export function useProjects(): UseProjectsResult { const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - - /* Schemas above this size are not fetched: get_graph_schema scans every - * node and edge, which on a multi-million-node index takes ~30 s. The card - * still shows totals from list_projects; only the label chips are absent. */ - const SCHEMA_LAZY_MAX_NODES = 1_000_000; + /* Bumped on unmount/refresh so a stale enrichment loop stops issuing + * requests and stops patching state. */ + const generationRef = useRef(0); const fetchProjects = useCallback(async () => { + const generation = ++generationRef.current; setLoading(true); setError(null); try { - const result = await callTool<{ projects: Project[] }>("list_projects"); + /* include_details carries node/edge totals and indexed_at — without it + * the tool returns names only and every count would need a schema scan. */ + const result = await callTool<{ projects: Project[] }>("list_projects", { + include_details: true, + limit: 100, + }); + if (generation !== generationRef.current) return; const list = result.projects ?? []; /* Render immediately from list_projects' own totals… */ - setProjects(list.map((p) => ({ project: p, schema: null }))); + setProjects( + list.map((p) => ({ project: p, schema: schemaCache.get(p.name) ?? null })), + ); setLoading(false); - /* …then enrich with per-label schemas one at a time, patching each - * card as its schema lands. Sequential on purpose: N parallel schema - * scans would serialize behind the store lock anyway. */ - for (const p of list) { - if ((p.nodes ?? 0) > SCHEMA_LAZY_MAX_NODES) continue; + /* …then enrich small projects with per-label schemas, one at a time, + * patching each card as its schema lands. Sequential on purpose — N + * parallel scans would serialize behind the store lock anyway — and + * abandoned as soon as the tab unmounts or refreshes. */ + const candidates = list + .filter( + (p) => !schemaCache.has(p.name) && (p.nodes ?? Infinity) <= SCHEMA_LAZY_MAX_NODES, + ) + .slice(0, SCHEMA_LAZY_MAX_PROJECTS); + for (const p of candidates) { + if (generation !== generationRef.current) return; try { const schema = await callTool("get_graph_schema", { project: p.name, }); + schemaCache.set(p.name, schema); + if (generation !== generationRef.current) return; setProjects((prev) => prev.map((info) => info.project.name === p.name ? { ...info, schema } : info, @@ -54,6 +81,7 @@ export function useProjects(): UseProjectsResult { } } } catch (e) { + if (generation !== generationRef.current) return; setError(e instanceof Error ? e.message : "Failed to fetch projects"); setLoading(false); } @@ -61,6 +89,10 @@ export function useProjects(): UseProjectsResult { useEffect(() => { fetchProjects(); + return () => { + /* Stop any in-flight enrichment loop when the tab goes away. */ + generationRef.current++; + }; }, [fetchProjects]); return { projects, loading, error, refresh: fetchProjects };