Expose knowledge graph data in the Memory view through a localized, resilient dashboard panel. - Add protected knowledge graph API routes and access helpers. - Add a Memory view tab, graph panel, loading/error states, and localized labels. - Document graph navigation and cover access, route, hook, and component behavior. Files changed: .changeset/fn-9038-knowledge-graph-tab.md | 7 + docs/dashboard-guide.md | 2 + docs/knowledge-graph.md | 10 ++ packages/dashboard/app/api/legacy.ts | 17 ++- packages/dashboard/app/api/system/index.ts | 1 + .../dashboard/app/api/system/knowledge-graph.ts | 23 +++ .../app/components/KnowledgeGraphPanel.css | 35 +++++ .../app/components/KnowledgeGraphPanel.tsx | 68 +++++++++ packages/dashboard/app/components/MemoryView.tsx | 15 +- .../__tests__/KnowledgeGraphPanel.test.tsx | 49 +++++++ .../app/components/__tests__/MemoryView.test.tsx | 13 ++ .../app/hooks/__tests__/useKnowledgeGraph.test.ts | 66 +++++++++ packages/dashboard/app/hooks/useKnowledgeGraph.ts | 94 ++++++++++++ .../src/__tests__/knowledge-graph-access.test.ts | 88 ++++++++++++ packages/dashboard/src/knowledge-graph-access.ts | 160 +++++++++++++++++++++ .../register-knowledge-graph-routes.test.ts | 75 ++++++++++ .../src/routes/register-knowledge-routes.ts | 120 ++++++++++++++++ packages/i18n/locales/en/app.json | 15 +- packages/i18n/locales/es/app.json | 15 +- packages/i18n/locales/fr/app.json | 15 +- packages/i18n/locales/ko/app.json | 15 +- packages/i18n/locales/pt-BR/app.json | 15 +- packages/i18n/locales/zh-CN/app.json | 15 +- packages/i18n/locales/zh-TW/app.json | 15 +- 24 files changed, 939 insertions(+), 9 deletions(-) Fusion-Task-Id: FN-9038 Fusion-Task-Lineage: 4200e214-ad22-4f92-b980-c9bcb8ffd3b9 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
69 lines
13 KiB
TypeScript
69 lines
13 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useKnowledgeGraph } from "../hooks/useKnowledgeGraph.js";
|
|
import type { KnowledgeGraphEdge } from "../api/system/knowledge-graph.js";
|
|
import "./KnowledgeGraphPanel.css";
|
|
|
|
const NODE_KINDS = ["file", "module", "symbol", "doc-concept", "rationale"] as const;
|
|
/* FNXC:KnowledgeGraphDashboard 2026-08-13-23:21: Symbol filter values must exactly match the core SymbolKind union because the API rejects unknown enum values rather than silently widening a query. */
|
|
const SYMBOL_KINDS = ["function", "class", "interface", "type-alias", "enum", "variable", "namespace", "alias"] as const;
|
|
const EDGE_KINDS = ["contains", "imports", "re-exports", "relates-to", "rationale-supports"] as const;
|
|
|
|
/** FNXC:KnowledgeGraphDashboard 2026-08-13-23:30: Each result is a bounded graph slice; node-to-node buttons are the navigation model instead of a whole-graph canvas. */
|
|
function EndpointButton({ id, onSelect }: { id: string; onSelect: (id: string) => void }) {
|
|
return <button type="button" className="btn btn-ghost knowledge-graph-endpoint" onClick={() => onSelect(id)}>{id}</button>;
|
|
}
|
|
|
|
function EdgeList({ title, edges, total, endpoint, onSelect }: { title: string; edges: KnowledgeGraphEdge[]; total: number; endpoint: "from" | "to"; onSelect: (id: string) => void }) {
|
|
return <section className="knowledge-graph-edge-list"><h4>{title} <span>showing {edges.length} of {total}</span></h4>{edges.length === 0 ? <p className="knowledge-graph-muted">None</p> : <ul>{edges.map((edge) => <li key={edge.id}><span>{edge.kind}</span><span className={`knowledge-graph-provenance knowledge-graph-provenance--${edge.provenance}`}>{edge.provenance}</span><EndpointButton id={edge[endpoint]} onSelect={onSelect} /></li>)}</ul>}</section>;
|
|
}
|
|
|
|
export function KnowledgeGraphPanel({ projectId, addToast }: { projectId?: string; addToast: (message: string, type: "success" | "error" | "info") => void }) {
|
|
const { t } = useTranslation("app");
|
|
const graph = useKnowledgeGraph({ projectId, enabled: true });
|
|
const [force, setForce] = useState(false);
|
|
useEffect(() => { if (graph.error) addToast(graph.error, "error"); }, [addToast, graph.error]);
|
|
|
|
/* FNXC:KnowledgeGraphDashboard 2026-08-13-23:21: A failed status request is an error state, not an indefinite loading state, so operators can distinguish an unavailable graph artifact from a failed dashboard request. */
|
|
if (!graph.status && graph.error) return <div className="knowledge-graph-panel knowledge-graph-error" data-testid="knowledge-graph-error">{graph.error}</div>;
|
|
if (!graph.status) return <div className="knowledge-graph-panel" data-testid="knowledge-graph-loading">{t("memory.graphLoading", "Loading knowledge graph…")}</div>;
|
|
if (!graph.status.available) return <div className="knowledge-graph-panel knowledge-graph-empty" data-testid="knowledge-graph-no-graph"><p>{t("memory.graphUnavailable", "Graph unavailable")}: {graph.status.recoveryReason}</p><button type="button" className="btn" onClick={() => void graph.rebuild(false)}>{t("memory.graphBuild", "Build graph")}</button></div>;
|
|
|
|
const setFilter = (patch: Partial<typeof graph.filters>) => graph.setFilters({ ...graph.filters, ...patch, offset: 0 });
|
|
const updateKinds = (kind: string) => {
|
|
const kinds = graph.filters.kinds ?? [];
|
|
setFilter({ kinds: kinds.includes(kind) ? kinds.filter((item) => item !== kind) : [...kinds, kind] });
|
|
};
|
|
const updateEdgeKinds = (kind: string) => {
|
|
const edgeKinds = graph.neighborOptions.edgeKinds ?? [];
|
|
graph.setNeighborOptions({ ...graph.neighborOptions, edgeKinds: edgeKinds.includes(kind) ? edgeKinds.filter((item) => item !== kind) : [...edgeKinds, kind] });
|
|
};
|
|
const selected = graph.selectedId;
|
|
const limits = graph.status.pathLimits;
|
|
const foundPath = graph.pathResult?.outcome === "found" ? graph.pathResult.path : null;
|
|
const foundHops = graph.pathResult?.outcome === "found" ? graph.pathResult.hops : null;
|
|
|
|
return <section className="knowledge-graph-panel" data-testid="knowledge-graph-panel">
|
|
<header className="knowledge-graph-status card"><div><strong>{graph.status.nodeCount} {t("memory.graphNodes", "nodes")} · {graph.status.edgeCount} {t("memory.graphEdges", "edges")}</strong><span>{graph.status.graphDir}</span></div><dl><div><dt>Node kinds</dt><dd>{Object.entries(graph.status.nodeKindCounts ?? {}).map(([kind, count]) => `${kind}: ${count}`).join(" · ") || "—"}</dd></div><div><dt>Edge kinds</dt><dd>{Object.entries(graph.status.edgeKindCounts ?? {}).map(([kind, count]) => `${kind}: ${count}`).join(" · ") || "—"}</dd></div><div><dt>Provenance</dt><dd>{Object.entries(graph.status.provenanceCounts ?? {}).map(([kind, count]) => `${kind}: ${count}`).join(" · ") || "—"}</dd></div></dl><label className="knowledge-graph-toggle"><input type="checkbox" checked={force} onChange={(event) => setForce(event.target.checked)} /> {t("memory.graphForce", "Force rebuild")}</label><button type="button" className="btn" disabled={graph.rebuilding} onClick={() => void graph.rebuild(force)}>{graph.rebuilding ? t("memory.graphRebuilding", "Rebuilding…") : t("memory.graphRebuild", "Rebuild")}</button></header>
|
|
|
|
<section className="knowledge-graph-filters card" aria-label={t("memory.graphFilters", "Graph filters")}>
|
|
<div className="knowledge-graph-kind-options">{NODE_KINDS.map((kind) => <label key={kind}><input type="checkbox" checked={(graph.filters.kinds ?? []).includes(kind)} onChange={() => updateKinds(kind)} /> {kind}</label>)}</div>
|
|
<input className="input" aria-label="Path prefix" placeholder="Path prefix" value={graph.filters.pathPrefix ?? ""} onChange={(event) => setFilter({ pathPrefix: event.target.value })} />
|
|
<input className="input" aria-label="Node id prefix" placeholder="Node id prefix" value={graph.filters.idPrefix ?? ""} onChange={(event) => setFilter({ idPrefix: event.target.value })} />
|
|
<input className="input" aria-label="Name regex" placeholder="Name regex" value={graph.filters.namePattern ?? ""} onChange={(event) => setFilter({ namePattern: event.target.value })} />
|
|
<select className="select" aria-label="FNXC area" value={graph.filters.fnxcArea ?? ""} onChange={(event) => setFilter({ fnxcArea: event.target.value || undefined })}><option value="">All FNXC areas</option>{graph.status.fnxcAreas?.map((area) => <option key={area} value={area}>{area}</option>)}</select>
|
|
<select className="select" aria-label="Symbol kind" value={graph.filters.symbolKind ?? ""} onChange={(event) => setFilter({ symbolKind: event.target.value || undefined })}><option value="">All symbol kinds</option>{SYMBOL_KINDS.map((kind) => <option key={kind}>{kind}</option>)}</select>
|
|
<select className="select" aria-label="Owner" value={graph.filters.owner ?? ""} onChange={(event) => setFilter({ owner: event.target.value || undefined })}><option value="">All owners</option><option value="file">file</option><option value="derived">derived</option></select>
|
|
<select className="select" aria-label="Result limit" value={graph.filters.limit ?? 50} onChange={(event) => setFilter({ limit: Number(event.target.value) })}>{[25, 50, 100, 200].map((limit) => <option key={limit} value={limit}>{limit} results</option>)}</select>
|
|
<button type="button" className="btn btn-ghost" onClick={graph.clearFilters}>{t("memory.graphClearFilters", "Clear filters")}</button>
|
|
</section>
|
|
|
|
<div className="knowledge-graph-layout"><section className="card knowledge-graph-results"><h3>{graph.nodes?.total ?? 0} {t("memory.graphResults", "results")}</h3>{graph.loading ? <p data-testid="knowledge-graph-searching">Searching…</p> : graph.nodes?.nodes.length ? <><ul>{graph.nodes.nodes.map((node) => <li key={node.id}><button type="button" className="btn btn-ghost" onClick={() => void graph.selectNode(node.id)}><strong>{node.kind}: {node.name}</strong><span>{node.id} · {node.ownerPath}:{node.source.line}</span></button></li>)}</ul><div className="knowledge-graph-pagination"><button type="button" className="btn btn-ghost" disabled={!graph.filters.offset} onClick={() => setFilter({ offset: Math.max(0, (graph.filters.offset ?? 0) - (graph.filters.limit ?? 50)) })}>Previous</button><button type="button" className="btn btn-ghost" disabled={(graph.filters.offset ?? 0) + (graph.filters.limit ?? 50) >= (graph.nodes?.total ?? 0)} onClick={() => setFilter({ offset: (graph.filters.offset ?? 0) + (graph.filters.limit ?? 50) })}>Next</button></div></> : <p data-testid="knowledge-graph-no-results">{t("memory.graphNoResults", "No matching nodes.")}</p>}</section>
|
|
<section className="card knowledge-graph-detail">{graph.detail ? <><h3>{graph.detail.node.name}</h3><p>{graph.detail.node.id}</p><p>{graph.detail.node.ownerPath}:{graph.detail.node.source.line}</p><EdgeList title="Outgoing" edges={graph.detail.outgoing} total={graph.detail.outgoingTotal} endpoint="to" onSelect={(id) => void graph.selectNode(id)} /><EdgeList title="Incoming" edges={graph.detail.incoming} total={graph.detail.incomingTotal} endpoint="from" onSelect={(id) => void graph.selectNode(id)} /></> : <p className="knowledge-graph-muted">Select a node to inspect its edges.</p>}</section></div>
|
|
|
|
{graph.detail && <section className="knowledge-graph-neighbors card"><h3>Neighbors</h3><select className="select" aria-label="Neighbor direction" value={graph.neighborOptions.direction} onChange={(event) => graph.setNeighborOptions({ ...graph.neighborOptions, direction: event.target.value as "out" | "in" | "both" })}><option value="out">Outgoing</option><option value="in">Incoming</option><option value="both">Both directions</option></select><select className="select" aria-label="Neighbor depth" value={graph.neighborOptions.depth} onChange={(event) => graph.setNeighborOptions({ ...graph.neighborOptions, depth: Number(event.target.value) })}>{[1, 2, 3].map((depth) => <option key={depth} value={depth}>Depth {depth}</option>)}</select><div className="knowledge-graph-kind-options">{EDGE_KINDS.map((kind) => <label key={kind}><input type="checkbox" checked={(graph.neighborOptions.edgeKinds ?? []).includes(kind)} onChange={() => updateEdgeKinds(kind)} /> {kind}</label>)}</div><button type="button" className="btn btn-ghost" onClick={() => void graph.refreshNeighbors()}>Refresh neighbors</button>{graph.neighborResults?.neighbors.length ? <ul>{graph.neighborResults.neighbors.map((neighbor) => <li key={neighbor.node.id}><span>distance {neighbor.distance}</span><EndpointButton id={neighbor.node.id} onSelect={(id) => void graph.selectNode(id)} /></li>)}</ul> : <p className="knowledge-graph-muted">No neighbors found.</p>}</section>}
|
|
|
|
<section className="knowledge-graph-path card"><h3>Shortest path</h3><input className="input" aria-label="From node id" placeholder="From node id" value={graph.pathInput.fromId} onChange={(event) => graph.setPathInput({ ...graph.pathInput, fromId: event.target.value })} /><input className="input" aria-label="To node id" placeholder="To node id" value={graph.pathInput.toId} onChange={(event) => graph.setPathInput({ ...graph.pathInput, toId: event.target.value })} /><input className="input" aria-label="Maximum hops" type="number" min="1" max={limits.maxHops} value={graph.pathInput.maxHops} onChange={(event) => graph.setPathInput({ ...graph.pathInput, maxHops: Number(event.target.value) })} />{selected && <><button type="button" className="btn btn-ghost" onClick={() => graph.setPathInput({ ...graph.pathInput, fromId: selected })}>Use selected as from</button><button type="button" className="btn btn-ghost" onClick={() => graph.setPathInput({ ...graph.pathInput, toId: selected })}>Use selected as to</button></>}<button type="button" className="btn" onClick={() => void graph.findPath()}>Find path</button>{foundPath && <div data-testid="knowledge-graph-path-found"><strong>{foundHops} hops</strong><div className="knowledge-graph-path-chain">{foundPath.nodes.map((node, index) => <span key={node.id}><EndpointButton id={node.id} onSelect={(id) => void graph.selectNode(id)} />{index < foundPath.edges.length && <em>{foundPath.edges[index].kind}</em>}</span>)}</div></div>}{graph.pathResult?.outcome === "not-found" && <p data-testid="knowledge-graph-path-not-found">No path exists between these nodes.</p>}{graph.pathResult?.outcome === "limit-reached" && <div data-testid="knowledge-graph-path-limit-reached"><p>No path found within {graph.pathResult.maxHops} hops; {graph.pathResult.limit} was reached.</p>{graph.pathInput.maxHops < limits.maxHops && <button type="button" className="btn btn-ghost" onClick={() => graph.setPathInput({ ...graph.pathInput, maxHops: Math.min(limits.maxHops, graph.pathInput.maxHops + 1) })}>Raise hop limit</button>}</div>}{graph.error && <p data-testid="knowledge-graph-path-error" className="knowledge-graph-error">{graph.error}</p>}</section>
|
|
</section>;
|
|
}
|