fix(ui): Projects enrichment — pass include_details, cancel on unmount, cache schemas

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 <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-08-24 09:16:34 +02:00
parent 71a80ed151
commit b516eb20eb
+45 -13
View File
@@ -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<string, SchemaInfo>();
export function useProjects(): UseProjectsResult {
const [projects, setProjects] = useState<ProjectInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<SchemaInfo>("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 };