diff --git a/graph-ui/src/galaxy/GalaxyPanel.layers.test.tsx b/graph-ui/src/galaxy/GalaxyPanel.layers.test.tsx new file mode 100644 index 000000000..22e613f27 --- /dev/null +++ b/graph-ui/src/galaxy/GalaxyPanel.layers.test.tsx @@ -0,0 +1,96 @@ +// @vitest-environment jsdom +/* + * Handtest K8: eine tiefe Ebene haengt nicht mehr minutenlang. Die Leiste + * zeigt, was schon geladen ist, "−" bricht das Laden ab und kehrt sofort zur + * vorigen Ebene zurueck, und eine Ebene ueber dem Render-Limit stoppt dort und + * sagt, dass sie unvollstaendig ist. + */ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import GalaxyPanel from './GalaxyPanel'; +import { viewPreferencesKey } from '../settings/view-preferences'; +import { scopeFetch, scopeNode } from './test-scope-fetch'; +import type { GraphEdge } from './types'; + +vi.mock('./GraphScene', async importOriginal => ({ + ...await importOriginal(), + GraphScene: () => , +})); + +let host: HTMLDivElement, root: Root; +beforeEach(() => { + (globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true; + host = document.createElement('div'); document.body.append(host); root = createRoot(host); +}); +afterEach(async () => { + await act(async () => root.unmount()); host.remove(); globalThis.__atlasGalaxy = undefined; + window.localStorage.clear(); +}); + +const seam = () => globalThis.__atlasGalaxy!; +const settle = (check: () => void) => vi.waitFor(async () => { + await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); + check(); +}); +const status = () => host.querySelector('.atlas-graph-scope-count'); +const button = (label: string) => [...host.querySelectorAll('button')].find(entry => entry.textContent === label)!; +const minus = () => host.querySelector('button[aria-label="Remove graph layer"]')!; +const layers = () => [...host.querySelectorAll('.atlas-graph-exploration span')].map(entry => entry.textContent ?? '').find(text => /^\d+ layers?$/.test(text)); + +it('K8: shows loaded nodes and edges while a layer loads, and "−" cancels back to the previous layer at once', async () => { + const nodes = [1, 2, 3, 4, 5, 6].map(id => scopeNode(id)); + const edges: GraphEdge[] = [{ id: 1, source: 1, target: 2, type: 'CALLS' }, { id: 2, source: 2, target: 3, type: 'CALLS' }, + { id: 3, source: 4, target: 2, type: 'CALLS' }, { id: 4, source: 3, target: 5, type: 'CALLS' }, { id: 5, source: 6, target: 1, type: 'TESTS' }]; + let block = false, release: () => void = () => {}; + let edgeQueries = 0; + const { fetch } = scopeFetch({ nodes, edges, gate: async () => { + edgeQueries += 1; + // Layer 2: the outbound batch arrives, the inbound one hangs like the slow server. + if (block && edgeQueries > 1) await new Promise(resolve => { release = resolve; }); + } }); + await act(async () => root.render()); + await settle(() => expect(seam().nodes).toBe(6)); + await act(async () => { seam().clickNode('sample.n1'); }); + await settle(() => expect(status()?.textContent).toBe('3 nodes · 2 edges')); + expect(minus().disabled).toBe(true); + + block = true; edgeQueries = 0; + await act(async () => button('Expand +1').click()); + await settle(() => expect(status()?.textContent).toBe('Loading layer 2: 4 nodes, 3 edges so far')); + expect(status()?.getAttribute('data-state')).toBe('loading'); + // While loading, "−" is the way out, and it says so. + expect(minus().disabled).toBe(false); + expect(minus().title).toBe('Cancel loading layer 2 and return to 1 layer'); + await act(async () => minus().click()); + // At once: the previous layer is complete again, nothing waits for the hanging request. + expect(layers()).toBe('1 layer'); + expect(status()?.getAttribute('data-state')).not.toBe('loading'); + expect(minus().disabled).toBe(true); + await settle(() => expect(status()?.textContent).toBe('3 nodes · 2 edges')); + await act(async () => { release(); }); + await settle(() => expect(status()?.textContent).toBe('3 nodes · 2 edges')); +}); + +it('K8: a layer past the render limit stops there, says it is partial and cannot be expanded further', async () => { + window.localStorage.setItem(viewPreferencesKey('sample'), JSON.stringify({ version: 1, preferences: { galaxyNodes: 500, galaxyEdges: 1000 } })); + const fan = Array.from({ length: 600 }, (_, at) => scopeNode(100 + at)); + const nodes = [scopeNode(1), scopeNode(2), ...fan]; + const edges: GraphEdge[] = [{ id: 1, source: 1, target: 2, type: 'CALLS' }, + ...fan.map((node, at) => ({ id: 10 + at, source: 2, target: node.id, type: 'CALLS' }))]; + await act(async () => root.render()); + await settle(() => expect(seam().nodes).toBeGreaterThan(0)); + await act(async () => { seam().clickNode('sample.n1'); }); + await settle(() => expect(status()?.textContent).toBe('2 nodes · 1 edge')); + expect(button('Expand +1').title).toContain('Load layer 2: 1 node to expand'); + + await act(async () => button('Expand +1').click()); + await settle(() => expect(status()?.textContent).toBe('602 nodes · 601 edges · partial')); + expect(status()?.getAttribute('data-state')).toBe('partial'); + expect(status()?.getAttribute('title')).toBe('Layer 2 stopped at the render limit of 500 nodes. Raise the limit under Limits or trace fewer edge types to load all of it.'); + expect(button('Expand +1').disabled).toBe(true); + expect(button('Expand +1').title).toContain('stopped at the render limit'); + // The partial layer can still be left the normal way. + await act(async () => minus().click()); + await settle(() => expect(status()?.textContent).toBe('2 nodes · 1 edge')); +}); diff --git a/graph-ui/src/galaxy/GalaxyPanel.reader-focus.test.tsx b/graph-ui/src/galaxy/GalaxyPanel.reader-focus.test.tsx index 4688a5bc0..69d1db84f 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.reader-focus.test.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.reader-focus.test.tsx @@ -114,7 +114,8 @@ it('does not report a file missing from a capped layout while its indexed scope return source(url, init); }); await act(async () => root.render()); - expect(host.textContent).toContain('Loading relationships'); + // Hand test K8: the status names the layer that is loading instead of an endless "Loading relationships". + expect(host.textContent).toContain('Loading layer 1…'); expect(host.querySelector('[data-testid="atlas-galaxy-note"]')).toBeNull(); await act(async () => { release(); }); expect(host.textContent).toContain('All indexed direct dependencies included.'); diff --git a/graph-ui/src/galaxy/GalaxyPanel.tsx b/graph-ui/src/galaxy/GalaxyPanel.tsx index 5a396816a..b5c5737cc 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.tsx @@ -139,13 +139,13 @@ import GalaxyNavigator from './GalaxyNavigator'; import { TraceEdgeFilter } from './TraceEdgeFilter'; import { PathPicker, PathSteps } from './ScopePathControls'; import { callOrder, pathNodes, shortestScopePath, type ScopePathStep } from './scope-path'; -import { galaxyHistoryText, galaxyPathText, galaxyToolbarText } from './galaxy-strings'; +import { galaxyHistoryText, galaxyLayerText, galaxyPathText, galaxyToolbarText } from './galaxy-strings'; import { emptyNavigationHistory, moveNavigation, peekNavigation, pushNavigation } from '../graph/navigation-history'; import { galaxyHistoryOptions, historyEntryDetail, historyEntryLabel, scopeIdentity, type GalaxyHistoryEntry, type ScopeTrail } from './scope-history'; import { useOrganicLayout } from './use-organic-layout'; import RenderProgress from './RenderProgress'; import { useGraphScope } from './use-graph-scope'; -import { limitGraphRender, scenePictureFor, scopedHierarchy } from './graph-scope'; +import { limitGraphRender, nextLayerEstimate, scenePictureFor, scopedHierarchy } from './graph-scope'; import './graph-exploration.css'; import { layoutNodeForSelection } from './selected-node'; import { galaxyScopeEvidence, useSelectionEvidence, type SelectionEvidenceListener } from './selection-evidence'; @@ -769,7 +769,9 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { const changeTraceTypes = useCallback((types: string[] | undefined) => setTraceFilter({ project, types }), [project]); const scope = useGraphScope({ project, layout, filePath: props.workspaceExpanded ? undefined : props.focusFilePath, - range: props.focusSourceRange, fetch: props.fetch, edgeTypes: traceTypes }); + range: props.focusSourceRange, fetch: props.fetch, edgeTypes: traceTypes, + // Handtest K8: eine Ebene ueber dem Render-Limit haelt dort an, statt minutenlang weiterzuladen. + ...(props.workspaceExpanded ? { limits: { nodes: nodeBudget, edges: edgeBudget } } : {}) }); const organicHistory = useRef<{ key: string; depth: number; data: GraphData } | undefined>(undefined); const organicKey = JSON.stringify([project, scope.scope, scope.direction, traceTypes]); const organicOptions = useMemo(() => { @@ -1985,6 +1987,16 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { } : null; + /* Was die Leiste ueber das Laden der Ebenen sagt (Handtest K8). */ + const partial = scope.complete ? scope.result?.partial : undefined; + const scopeState = scope.validating ? 'checking' : scope.loading ? 'loading' : organicTask.loading ? 'arranging' + : partial ? 'partial' : scope.complete ? 'complete' : 'preview'; + const estimate = scope.complete ? nextLayerEstimate(scope.result) : undefined; + const expandWarning = Boolean(estimate && props.workspaceExpanded && (data?.nodes.length ?? 0) + estimate.estimate > nodeBudget); + const expandTitle = partial ? galaxyLayerText.expandPartial : scope.result?.exhausted ? galaxyLayerText.expandEnd + : estimate ? `${expandWarning ? `${galaxyLayerText.expandOverLimit} ` : ''}${galaxyLayerText.expandTitle(estimate.layer, estimate.frontier, estimate.estimate, nodeBudget)}` + : undefined; + /* * Die Vorgabe der Ansicht, mit der Wahl des Lesers darauf. * @@ -2474,10 +2486,17 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { } {props.workspaceExpanded && kind.type)} selected={traceTypes} onChange={changeTraceTypes} />} - {scope.depth} {scope.depth === 1 ? 'layer' : 'layers'} - {props.workspaceExpanded && mode === 'galaxy' && <> { setTrail({ key: organicKey, kind: 'path', target: node.id, name: node.name }); setTrailStep(0); }} /> @@ -2486,9 +2505,13 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { onClick={() => { if (trail?.key === organicKey && trail.kind === 'calls') clearTrail(); else { setTrail({ key: organicKey, kind: 'calls' }); setTrailStep(0); } }}> {galaxyPathText.callOrder} } - {scope.validating ? 'Checking index…' : scope.loading ? 'Loading relationships…' : organicTask.loading ? 'Arranging nodes…' - : scope.complete ? `${data?.nodes.length ?? 0} ${data?.nodes.length === 1 ? 'node' : 'nodes'} · ${data?.edges.length ?? 0} ${data?.edges.length === 1 ? 'edge' : 'edges'}${scope.result?.exhausted ? ' · end of trace' : ''}` - : 'Partial preview'} + + {scope.validating ? galaxyLayerText.checking + : scope.loading ? scope.progress ? galaxyLayerText.loadingProgress(scope.progress.layer, scope.progress.nodes, scope.progress.edges) : galaxyLayerText.loading(scope.depth) + : organicTask.loading ? galaxyLayerText.arranging + : scope.complete ? `${galaxyLayerText.counts(data?.nodes.length ?? 0, data?.edges.length ?? 0)}${partial ? galaxyLayerText.partial : scope.result?.exhausted ? galaxyLayerText.endOfTrace : ''}` + : galaxyLayerText.partialPreview} {scope.error && Some relationships could not be loaded. } {!props.workspaceExpanded && scope.complete && All indexed direct dependencies included.} {mode === 'galaxy' && organic && organic.groups.length > 1 && {galaxyToolbarText.groups(organic.groups.length)}} @@ -2630,7 +2653,8 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { )}
- + {trailView && } {/* diff --git a/graph-ui/src/galaxy/galaxy-strings.ts b/graph-ui/src/galaxy/galaxy-strings.ts index e7381e51b..d25e68d25 100644 --- a/graph-ui/src/galaxy/galaxy-strings.ts +++ b/graph-ui/src/galaxy/galaxy-strings.ts @@ -56,6 +56,34 @@ export const galaxyHistoryText = { pathTo: (name: string) => `path to ${name}`, }; +const count = (value: number, one: string, many: string) => `${value.toLocaleString()} ${value === 1 ? one : many}`; + +/** Loading, cancelling and the render limit of a scope layer (hand test K8). */ +export const galaxyLayerText = { + checking: 'Checking index…', + loading: (layer: number) => `Loading layer ${layer}…`, + // A comma, not the dot of the finished counts: tools that wait for "N nodes · M edges" must not take this for done. + loadingProgress: (layer: number, nodes: number, edges: number) => + `Loading layer ${layer}: ${count(nodes, 'node', 'nodes')}, ${count(edges, 'edge', 'edges')} so far`, + arranging: 'Arranging nodes…', + counts: (nodes: number, edges: number) => `${count(nodes, 'node', 'nodes')} · ${count(edges, 'edge', 'edges')}`, + endOfTrace: ' · end of trace', + partial: ' · partial', + partialPreview: 'Partial preview', + previewLoading: (layer: number) => `Partial preview while layer ${layer} loads`, + partialTitle: (layer: number, limit: number, kind: 'nodes' | 'edges') => + `Layer ${layer} stopped at the render limit of ${limit.toLocaleString()} ${kind}. Raise the limit under Limits or trace fewer edge types to load all of it.`, + removeLayer: 'Remove the outermost layer', + cancelLoading: (layer: number) => `Cancel loading layer ${layer} and return to ${layer - 1 === 1 ? '1 layer' : `${layer - 1} layers`}`, + /* Measured, not promised: a hub at the edge can bring far more than the last layer did (JSONBAgg layer 3: about 400 expected, over 9,000 loaded). */ + expandTitle: (layer: number, frontier: number, estimate: number, limit: number) => + `Load layer ${layer}: ${count(frontier, 'node', 'nodes')} to expand. Growing like the last layer it adds about ${estimate.toLocaleString()} nodes; ` + + `a hub can add many more. Loading stops at the render limit of ${limit.toLocaleString()} nodes and marks the layer partial.`, + expandOverLimit: 'Likely past the render limit.', + expandPartial: 'This layer stopped at the render limit, so there is no complete edge to grow from. Raise the limit under Limits first.', + expandEnd: 'End of trace: no relationship leads further.', +}; + /** Compact toolbar words, so the scoped toolbar keeps to one row at 1600 px. */ export const galaxyToolbarText = { groups: (count: number) => `${count.toLocaleString()} groups`, diff --git a/graph-ui/src/galaxy/graph-exploration.css b/graph-ui/src/galaxy/graph-exploration.css index c87ebc648..78b6e13c1 100644 --- a/graph-ui/src/galaxy/graph-exploration.css +++ b/graph-ui/src/galaxy/graph-exploration.css @@ -14,6 +14,9 @@ .atlas-graph-scope-name { max-width:14rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .atlas-graph-scope-count { color:#93b2a1; margin-inline-start:auto; font-variant-numeric:tabular-nums; white-space:nowrap; } .atlas-graph-scope-groups { white-space:nowrap; } +/* Handtest K8: eine Ebene, die am Render-Limit anhielt, und ein Expand, das vermutlich dorthin fuehrt. */ +.atlas-graph-scope-count[data-state=partial] { color:#d8b27a; cursor:help; } +.atlas-graph-exploration button[data-warning=true] { border-color:#9b7b4a; } /* Der Deckel im Scope: dieselbe Bauform wie die Kantenarten (Review-Befund G7). */ .atlas-graph-limits { position:relative; } .atlas-graph-limits > summary { padding:.25rem .5rem; border:1px solid #29473a; border-radius:6px; cursor:pointer; color:#b5c9bc; white-space:nowrap; } diff --git a/graph-ui/src/galaxy/graph-scope.test.ts b/graph-ui/src/galaxy/graph-scope.test.ts index 8c4ca316a..b9361cfca 100644 --- a/graph-ui/src/galaxy/graph-scope.test.ts +++ b/graph-ui/src/galaxy/graph-scope.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { arrangeScopedGraph, graphEdgeTypesKey, limitGraphRender, loadGraphScope, readGraphPages, scenePictureFor, scopedHierarchy, type GraphQueryClient } from './graph-scope'; +import { arrangeScopedGraph, graphEdgeTypesKey, limitGraphRender, loadGraphScope, nextLayerEstimate, readGraphPages, scenePictureFor, scopedHierarchy, type GraphQueryClient } from './graph-scope'; import type { QueryGraphResult } from '../provider/rpc-schemas'; import type { GraphData, GraphNode } from './types'; @@ -239,3 +239,63 @@ it('keeps the whole graph on screen while the first scope picture is still empty expect(scenePictureFor(current, stale, layout)).toBe(current); expect(scenePictureFor(undefined, stale, layout)).toBe(stale); }); + +describe('handtest K8: deep layers load in few large pages, report progress and stop at the render limit', () => { + const scope = { kind: 'node' as const, id: 1, name: 'n1', qualifiedName: 'p.n1' }; + const star = (from: GraphNode, count: number, first: number) => Array.from({ length: count }, (_, at) => edgeRow(first + at, from, node(first + at))); + + it('asks for large pages, so one hop is not split into dozens of re-executed continuations', async () => { + const a = node(1), b = node(2); + const queryGraph = vi.fn().mockResolvedValueOnce(page([row(a)])) + .mockResolvedValueOnce(page([edgeRow(1, a, b)])).mockResolvedValueOnce(page([])); + await loadGraphScope('p', scope, 1, 'both', undefined, { client: { queryGraph } }); + expect(queryGraph).toHaveBeenCalledTimes(3); + for (const call of queryGraph.mock.calls) { + expect(call[3]?.maxRows).toBeGreaterThanOrEqual(2000); + expect(call[3]?.maxOutputTokens).toBeGreaterThanOrEqual(100_000); + } + }); + + it('reports loaded nodes and edges after every request', async () => { + const a = node(1), b = node(2), c = node(3); + const queryGraph = vi.fn().mockResolvedValueOnce(page([row(a)])) + .mockResolvedValueOnce(page([edgeRow(1, a, b)])).mockResolvedValueOnce(page([edgeRow(2, b, c)])); + const progress = vi.fn(); + await loadGraphScope('p', scope, 2, 'outbound', undefined, { client: { queryGraph }, onProgress: progress }); + expect(progress.mock.calls.map(([value]) => [value.layer, value.nodes, value.edges, value.requests])).toEqual([[1, 2, 1, 2], [2, 3, 2, 3]]); + }); + + it('stops at the node limit, marks the layer partial and never continues from it', async () => { + const a = node(1), b = node(2); + const queryGraph = vi.fn().mockResolvedValueOnce(page([row(a)])) + .mockResolvedValueOnce(page([edgeRow(1, a, b)])) + .mockResolvedValueOnce(page(star(b, 6, 10))); + const result = await loadGraphScope('p', scope, 3, 'outbound', undefined, { client: { queryGraph }, limits: { nodes: 5, edges: 1000 } }); + expect(result.partial).toEqual({ layer: 2, nodes: 8, edges: 7, limit: 'nodes' }); + expect(result.exhausted).toBe(false); + expect(result.depth).toBe(2); + // Hop 3 was never requested: the layer before it was already over the limit. + expect(queryGraph).toHaveBeenCalledTimes(3); + const fresh = vi.fn().mockResolvedValueOnce(page([row(a)])).mockResolvedValue(page([])); + await loadGraphScope('p', scope, 3, 'outbound', undefined, { client: { queryGraph: fresh }, previous: result, limits: { nodes: 50, edges: 1000 } }); + // A partial layer is no base for the next one: the load starts again from the root. + expect(fresh.mock.calls[0]![1]).toMatch(/^MATCH \(n\) WHERE/); + }); + + it('estimates the next layer from the frontier and the growth of the last layer', () => { + const nodes = [1, 2, 3, 4, 5, 6, 7].map(id => node(id)); + const levels = new Map([[1, 0], [2, 1], [3, 1], [4, 2], [5, 2], [6, 2], [7, 2]]); + const scoped = { data: { nodes, edges: [], total_nodes: 7 }, roots: new Set([1]), depth: 2, exhausted: false, levels }; + expect(nextLayerEstimate(scoped)).toEqual({ layer: 3, frontier: 4, perNode: 2, estimate: 8 }); + expect(nextLayerEstimate({ ...scoped, exhausted: true })).toBeUndefined(); + expect(nextLayerEstimate({ ...scoped, partial: { layer: 2, nodes: 7, edges: 6, limit: 'nodes' } })).toBeUndefined(); + }); + + it('stops at the edge limit too', async () => { + const a = node(1); + const queryGraph = vi.fn().mockResolvedValueOnce(page([row(a)])) + .mockResolvedValueOnce(page(star(a, 4, 10))); + const result = await loadGraphScope('p', scope, 2, 'outbound', undefined, { client: { queryGraph }, limits: { nodes: 100, edges: 3 } }); + expect(result.partial).toMatchObject({ layer: 1, edges: 4, limit: 'edges' }); + }); +}); diff --git a/graph-ui/src/galaxy/graph-scope.ts b/graph-ui/src/galaxy/graph-scope.ts index 41ba49c85..a62600a4c 100644 --- a/graph-ui/src/galaxy/graph-scope.ts +++ b/graph-ui/src/galaxy/graph-scope.ts @@ -8,8 +8,23 @@ import type { GraphNeighborhoodTransaction } from './graph-neighborhood-cache'; export type TraceDirection = 'both' | 'inbound' | 'outbound'; export type GraphScope = { kind: 'node'; id: number; name: string; qualifiedName?: string } | { kind: 'symbol'; qualifiedName: string; name: string } | { kind: 'file' | 'folder'; path: string; name: string; range?: SourceFocusRange }; -export interface ScopedGraph { data: GraphData; roots: Set; depth: number; exhausted: boolean; frontier?: number[]; levels?: Map; traversalKey?: string } -export interface GraphQueryClient { queryGraph(project: string, query: string, cursor?: string): Promise } +/** A layer that stopped at the render limit (hand test K8): what was loaded, and which limit stopped it. */ +export interface ScopePartial { layer: number; nodes: number; edges: number; limit: 'nodes' | 'edges' } +export interface ScopedGraph { data: GraphData; roots: Set; depth: number; exhausted: boolean; frontier?: number[]; levels?: Map; traversalKey?: string; partial?: ScopePartial } +/** How large one query_graph page may be. */ +export interface QueryBudget { maxRows?: number; maxOutputTokens?: number } +export interface GraphQueryClient { queryGraph(project: string, query: string, cursor?: string, budget?: QueryBudget): Promise } +/** What a running load has gathered so far, for the toolbar instead of an endless "Loading". */ +export interface ScopeProgress { layer: number; nodes: number; edges: number; requests: number } +/* + * Grosse Seiten, und das ist die eigentliche Antwort auf K8. Der Server rechnet + * eine Abfrage fuer JEDE Fortsetzungsseite vollstaendig neu und schickt davon + * nur ein Fenster (Vorgabe 200 Zeilen und rund 3.200 Tokens, also bei dieser + * Spaltenbreite etwa 30 Zeilen). Die dritte Ebene um JSONBAgg brauchte so ueber + * 500 Aufrufe nacheinander, je rund 0,6 s. Mit diesem Fenster ist eine Ebene + * eine Handvoll Aufrufe; die Ergebnisse selbst aendern sich nicht. + */ +export const SCOPE_PAGE_BUDGET: QueryBudget = { maxRows: 5000, maxOutputTokens: 600_000 }; /** Undefined includes every relationship type; an explicit empty list includes none. */ export function graphEdgeTypesKey(edgeTypes?: readonly string[]): string { return JSON.stringify(edgeTypes === undefined ? null : [...new Set(edgeTypes)].sort()); @@ -26,9 +41,11 @@ const nodeColumns = (alias: string, prefix: string) => [`id(${alias}) AS ${prefi /** The cursor binds a complete query result to one graph generation. Every * continuation is consumed; a daemon cap or missing cursor is never silently * accepted as the selected file's complete neighborhood. */ -export async function readGraphPages(client: GraphQueryClient, project: string, query: string, key: string, signal?: AbortSignal): Promise[]> { +export async function readGraphPages(client: GraphQueryClient, project: string, query: string, key: string, signal?: AbortSignal, + onRequest?: () => void): Promise[]> { signal?.throwIfAborted(); - let page = await client.queryGraph(project, query); + onRequest?.(); + let page = await client.queryGraph(project, query, undefined, SCOPE_PAGE_BUDGET); const columns = page.columns, total = page.total, records: Record[] = []; const cursors = new Set(), identities = new Set(); for (;;) { @@ -49,7 +66,8 @@ export async function readGraphPages(client: GraphQueryClient, project: string, if (!page.rows.length || cursors.has(page.nextCursor)) throw new Error('Graph pagination did not advance.'); if (page.nextOffset !== undefined && page.nextOffset !== records.length) throw new Error('Graph pagination skipped rows.'); cursors.add(page.nextCursor); signal?.throwIfAborted(); - page = await client.queryGraph(project, query, page.nextCursor); + onRequest?.(); + page = await client.queryGraph(project, query, page.nextCursor, SCOPE_PAGE_BUDGET); } } @@ -100,7 +118,11 @@ export function arrangeScopedGraph(nodes: readonly GraphNode[], edges: readonly } export async function loadGraphScope(project: string, scope: GraphScope, depth: number, direction: TraceDirection, - layout: GraphData | undefined, options: { fetch?: typeof globalThis.fetch; signal?: AbortSignal; client?: GraphQueryClient; previous?: ScopedGraph; edgeTypes?: readonly string[]; cache?: GraphNeighborhoodTransaction } = {}): Promise { + layout: GraphData | undefined, options: { fetch?: typeof globalThis.fetch; signal?: AbortSignal; client?: GraphQueryClient; previous?: ScopedGraph; edgeTypes?: readonly string[]; cache?: GraphNeighborhoodTransaction; + /** Called after every relationship request with what is loaded so far. */ + onProgress?: (progress: ScopeProgress) => void; + /** The render limits: a layer that grows past them stops and is marked partial. */ + limits?: { nodes: number; edges: number } } = {}): Promise { options.signal?.throwIfAborted(); const client = options.client ?? new RpcIntelligenceClient({ fetch: options.fetch, signal: options.signal }); const edgeTypes = options.edgeTypes === undefined ? undefined : [...new Set(options.edgeTypes)].sort(); @@ -117,12 +139,15 @@ export async function loadGraphScope(project: string, scope: GraphScope, depth: throw new Error('This node has no indexed qualified identity; its relationships cannot be retrieved reliably.'); const predicate = scope.kind === 'node' ? `n.qualified_name = "${escapeLiteral(scope.qualifiedName ?? known.get(scope.id)?.qualified_name ?? '')}"` : scope.kind === 'symbol' ? `n.qualified_name = "${escapeLiteral(scope.qualifiedName)}"` : scope.kind === 'file' ? `n.file_path = "${escapeLiteral(scope.path)}"` : `n.file_path STARTS WITH "${escapeLiteral(scope.path.replace(/\/$/, '') + '/')}"`; - const previous = options.previous?.traversalKey === traversalKey && options.previous.depth <= depth ? options.previous : undefined; + // A partial layer misses part of its frontier, so it is never the base of the next one. + const previous = options.previous?.traversalKey === traversalKey && options.previous.depth <= depth && !options.previous.partial ? options.previous : undefined; + let requests = 0; + const counted = () => { requests += 1; }; const cachedNode = scope.kind === 'node' ? options.cache?.node(scope.id) : undefined; let rootNodes = cachedNode && cachedNode.qualified_name === (scope.kind === 'node' ? scope.qualifiedName ?? known.get(scope.id)?.qualified_name : '') ? [cachedNode] : options.cache?.roots(predicate); if (!previous && !rootNodes) { - const rootRows = await readGraphPages(client, project, `MATCH (n) WHERE ${predicate} RETURN ${nodeColumns('n', '')}`, 'id', options.signal); + const rootRows = await readGraphPages(client, project, `MATCH (n) WHERE ${predicate} RETURN ${nodeColumns('n', '')}`, 'id', options.signal, counted); rootNodes = rootRows.map(row => readNode(row, '', known)); options.cache?.rememberRoots(predicate, rootNodes); } @@ -133,7 +158,11 @@ export async function loadGraphScope(project: string, scope: GraphScope, depth: const levels = new Map(previous?.levels ?? [...roots].map(identity => [identity, 0] as const)); const edges = new Map(previous?.data.edges.filter(edge => edge.id !== undefined).map(edge => [edge.id!, edge]) ?? []); let frontier = edgeTypes?.length === 0 ? [] : previous?.frontier ?? [...roots], reachedDepth = previous?.depth ?? 0; - for (let hop = reachedDepth; hop < Math.max(depth, roots.size > 1 ? 1 : 0) && frontier.length; hop++) { + let partial: ScopePartial | undefined; + const overLimit = (layer: number): ScopePartial | undefined => !options.limits ? undefined + : nodes.size > options.limits.nodes ? { layer, nodes: nodes.size, edges: edges.size, limit: 'nodes' } + : edges.size > options.limits.edges ? { layer, nodes: nodes.size, edges: edges.size, limit: 'edges' } : undefined; + for (let hop = reachedDepth; hop < Math.max(depth, roots.size > 1 ? 1 : 0) && frontier.length && !partial; hop++) { const next = new Set(); const fileBoundary = hop === 0 && (scope.kind === 'folder' || (scope.kind === 'file' && !scope.range)); const accept = (evidenceNodes: readonly GraphNode[], evidenceEdges: readonly GraphEdge[]) => { @@ -149,6 +178,7 @@ export async function loadGraphScope(project: string, scope: GraphScope, depth: }; const legs = direction === 'both' ? ['outbound', 'inbound'] as const : [direction]; for (const leg of legs) { + if (partial) break; const missing: number[] = []; for (const identity of frontier) { const cached = options.cache?.neighborhood(identity, leg, edgeTypes); @@ -156,7 +186,7 @@ export async function loadGraphScope(project: string, scope: GraphScope, depth: } const useBoundary = fileBoundary && missing.length === frontier.length; const batchSize = useBoundary ? Math.max(1, missing.length) : BATCH_SIZE; - for (let at = 0; at < missing.length; at += batchSize) { + for (let at = 0; at < missing.length && !partial; at += batchSize) { const batch = missing.slice(at, at + batchSize), batchIds = new Set(batch); const names = batch.map(value => nodes.get(value)?.qualified_name); if (!useBoundary && names.some(name => !name)) throw new Error('A selected graph node has no qualified identity; its dependencies cannot be loaded completely.'); @@ -168,7 +198,7 @@ export async function loadGraphScope(project: string, scope: GraphScope, depth: // Keep each predicate on the left seed, so early WHERE prunes before // adjacency expansion. An OR spanning a and b materializes the repo. const pattern = leg === 'inbound' ? `(b)<-${relationship}-(a)` : `(a)-${relationship}->(b)`; - const rows = await readGraphPages(client, project, `MATCH ${pattern} WHERE ${relation} RETURN id(r) AS edge_id, type(r) AS edge_type, r.line AS edge_line, ${nodeColumns('a', 'a_')}, ${nodeColumns('b', 'b_')}`, 'edge_id', options.signal); + const rows = await readGraphPages(client, project, `MATCH ${pattern} WHERE ${relation} RETURN id(r) AS edge_id, type(r) AS edge_type, r.line AS edge_line, ${nodeColumns('a', 'a_')}, ${nodeColumns('b', 'b_')}`, 'edge_id', options.signal, counted); const evidenceNodes = new Map(batch.map(identity => [identity, nodes.get(identity)!])); const evidenceEdges: GraphEdge[] = []; for (const row of rows) { @@ -185,6 +215,8 @@ export async function loadGraphScope(project: string, scope: GraphScope, depth: const verifiedNodes = [...evidenceNodes.values()]; options.cache?.rememberNeighborhood(batch, leg, edgeTypes, verifiedNodes, evidenceEdges); accept(verifiedNodes, evidenceEdges); + options.onProgress?.({ layer: hop + 1, nodes: nodes.size, edges: edges.size, requests }); + partial = overLimit(hop + 1); } } reachedDepth = hop + 1; @@ -195,7 +227,21 @@ export async function loadGraphScope(project: string, scope: GraphScope, depth: const initial = [...edges.values()].filter(edge => roots.has(edge.source) && roots.has(edge.target)); return { data: arrangeScopedGraph([...nodes.values()].filter(node => roots.has(node.id)), initial, roots, levels), roots, depth: 0, exhausted: edgeTypes?.length === 0, frontier: edgeTypes?.length === 0 ? [] : [...roots], levels, traversalKey }; } - return { data: arrangeScopedGraph([...nodes.values()], [...edges.values()], roots, levels), roots, depth: reachedDepth, exhausted: edgeTypes?.length === 0 || (depth > 0 && frontier.length === 0), frontier, levels, traversalKey }; + return { data: arrangeScopedGraph([...nodes.values()], [...edges.values()], roots, levels), roots, depth: reachedDepth, + exhausted: !partial && (edgeTypes?.length === 0 || (depth > 0 && frontier.length === 0)), frontier, levels, traversalKey, ...(partial ? { partial } : {}) }; +} + +/** Eine Schaetzung der naechsten Ebene, bevor sie geladen wird (K8): so viele + * Knoten stehen an ihrem Rand, und so viele neue brachte jeder Knoten der + * letzten Ebene. Eine Schaetzung und keine Zusage; die Grenze beim Laden + * selbst haelt die Ebene trotzdem am Render-Limit an. */ +export function nextLayerEstimate(scope: ScopedGraph | undefined): { layer: number; frontier: number; perNode: number; estimate: number } | undefined { + if (!scope?.levels || scope.exhausted || scope.partial) return undefined; + const at = (level: number) => scope.data.nodes.filter(node => (scope.roots.has(node.id) ? 0 : scope.levels!.get(node.id)) === level).length; + const frontier = at(scope.depth); + if (frontier === 0) return undefined; + const perNode = scope.depth > 0 ? frontier / Math.max(1, at(scope.depth - 1)) : 1; + return { layer: scope.depth + 1, frontier, perNode, estimate: Math.round(frontier * perNode) }; } /** Was die Galaxie zeigt, waehrend ein Scope angeordnet wird: das aktuelle diff --git a/graph-ui/src/galaxy/use-graph-scope.ts b/graph-ui/src/galaxy/use-graph-scope.ts index 2c7f9711c..c4f8988e5 100644 --- a/graph-ui/src/galaxy/use-graph-scope.ts +++ b/graph-ui/src/galaxy/use-graph-scope.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { graphEdgeTypesKey, loadGraphScope, type GraphScope, type ScopedGraph, type TraceDirection } from './graph-scope'; +import { graphEdgeTypesKey, loadGraphScope, type GraphScope, type ScopedGraph, type ScopeProgress, type TraceDirection } from './graph-scope'; import { RpcIntelligenceClient } from '../provider/rpc-client'; import { readerGraphFocus, type SourceFocusRange } from './reader-graph-focus'; import type { GraphData } from './types'; @@ -12,8 +12,10 @@ export function minimumScopeDepth(scope: GraphScope | undefined): number { } /** Scoped requests are independent of the whole-repository layout budget. */ -export function useGraphScope({ project, layout, filePath, range, fetch: fetchImpl, edgeTypes }: { +export function useGraphScope({ project, layout, filePath, range, fetch: fetchImpl, edgeTypes, limits }: { project: string; layout?: GraphData; filePath?: string; range?: SourceFocusRange; fetch?: typeof globalThis.fetch; edgeTypes?: readonly string[]; + /** The render limits: a layer that grows past them stops early and is marked partial (hand test K8). */ + limits?: { nodes: number; edges: number }; }) { const cache = useRef(new Map()); const [chosen, setChosen] = useState(); @@ -23,6 +25,7 @@ export function useGraphScope({ project, layout, filePath, range, fetch: fetchIm const [cachedPreview, setCachedPreview] = useState<{ key: string; value: ScopedGraph }>(); const [pendingPhase, setPendingPhase] = useState<{ key: string; validating: boolean }>(); const [failure, setFailure] = useState<{ key: string; message: string }>(); + const [progress, setProgress] = useState<{ key: string; value: ScopeProgress }>(); const [reload, setReload] = useState(0); const transportKey = graphNeighborhoodTransportKey(fetchImpl); const readerKey = JSON.stringify([transportKey, project, filePath, range?.startLine, range?.endLine]); @@ -34,7 +37,8 @@ export function useGraphScope({ project, layout, filePath, range, fetch: fetchIm const edgeTypesKey = graphEdgeTypesKey(edgeTypes); const selectedTypes = useMemo(() => edgeTypesKey === 'null' ? undefined : JSON.parse(edgeTypesKey) as string[], [edgeTypesKey]); const actualDirection = filePath ? 'both' : direction; - const scopeKey = JSON.stringify([transportKey, project, scope, actualDirection, edgeTypesKey, reload]); + const limitNodes = limits?.nodes, limitEdges = limits?.edges; + const scopeKey = JSON.stringify([transportKey, project, scope, actualDirection, edgeTypesKey, reload, limitNodes ?? null, limitEdges ?? null]); const key = JSON.stringify([scopeKey, actualDepth]); useEffect(() => { setChosen(undefined); setDepth(0); setDirection('both'); setResult(undefined); setFailure(undefined); }, [project]); useEffect(() => { @@ -74,6 +78,8 @@ export function useGraphScope({ project, layout, filePath, range, fetch: fetchIm const transaction = neighborhood?.begin(); const value = await loadGraphScope(project, scope, actualDepth, actualDirection, layout, { fetch: fetchImpl, signal: abort.signal, previous, edgeTypes: selectedTypes, cache: transaction, + ...(limitNodes !== undefined && limitEdges !== undefined ? { limits: { nodes: limitNodes, edges: limitEdges } } : {}), + onProgress: value => { if (!abort.signal.aborted) setProgress({ key, value }); }, client: { queryGraph: (...args) => { setPendingPhase({ key, validating: false }); return client.queryGraph(...args); } } }); @@ -130,6 +136,7 @@ export function useGraphScope({ project, layout, filePath, range, fetch: fetchIm }, [scope, layout, actualDepth, actualDirection, selectedTypes]); const minDepth = filePath ? 1 : minimumScopeDepth(scope); return { scope, depth: actualDepth, minDepth, direction, setDirection, + progress: progress?.key === key && !complete ? progress.value : undefined, result: complete ?? (cachedPreview?.key === key ? cachedPreview.value : undefined) ?? (result?.scopeKey === scopeKey && result.value.depth <= actualDepth ? result.value : fallback), loading: Boolean(scope && !complete && !error), error, validating: Boolean(scope && !complete && !error && pendingPhase?.key === key && pendingPhase.validating), diff --git a/graph-ui/src/provider/query-pages.test.ts b/graph-ui/src/provider/query-pages.test.ts index 957ef962d..8426cf1a8 100644 --- a/graph-ui/src/provider/query-pages.test.ts +++ b/graph-ui/src/provider/query-pages.test.ts @@ -18,6 +18,18 @@ it('reads cursor pages without losing the project, query or row order', async () expect(rpc.calls[1].args).toEqual({ project: 'project-a', query: 'MATCH query', cursor: 'q1.next' }); }); +it('sends a page budget as max_rows and max_output_tokens only when one is asked for (hand test K8)', async () => { + const { client: c, rpc } = client(); + await c.queryGraph('p', 'q', undefined, { maxRows: 5000, maxOutputTokens: 600000 }); + await c.queryGraph('p', 'q', 'q1.next', { maxRows: 5000, maxOutputTokens: 600000 }); + await c.queryGraph('p', 'q'); + expect(rpc.calls.map(call => call.args)).toEqual([ + { project: 'p', query: 'q', max_rows: 5000, max_output_tokens: 600000 }, + { project: 'p', query: 'q', cursor: 'q1.next', max_rows: 5000, max_output_tokens: 600000 }, + { project: 'p', query: 'q' }, + ]); +}); + it('keeps truncation metadata on the single-page API', async () => { expect(await client().client.queryGraph('p', 'q')).toMatchObject({ total: 2, returned: 1, truncated: true, nextCursor: 'q1.next' }); }); diff --git a/graph-ui/src/provider/rpc-client.ts b/graph-ui/src/provider/rpc-client.ts index aca4ba430..2315ef957 100644 --- a/graph-ui/src/provider/rpc-client.ts +++ b/graph-ui/src/provider/rpc-client.ts @@ -210,8 +210,12 @@ export class RpcIntelligenceClient { * schreibt an derselben Stelle `""`, und ohne die Uebersetzung wuerde ein * Bindestrich als Dateiname durch das Produkt wandern. */ - async queryGraph(project: string, query: string, cursor?: string): Promise { - const text = await this.text('query_graph', { project, query, ...(cursor ? { cursor } : {}) }); + async queryGraph(project: string, query: string, cursor?: string, + /* Groessere Seiten fuer Lader, die alles brauchen (Galaxy-Scope, Handtest K8). */ + budget?: { maxRows?: number; maxOutputTokens?: number }): Promise { + const text = await this.text('query_graph', { project, query, ...(cursor ? { cursor } : {}), + ...(budget?.maxRows ? { max_rows: budget.maxRows } : {}), + ...(budget?.maxOutputTokens ? { max_output_tokens: budget.maxOutputTokens } : {}) }); let parsed: CompactRows; try { parsed = parseCompactRows(text); diff --git a/graph-ui/tools/handtest-fixes-galaxy.mjs b/graph-ui/tools/handtest-fixes-galaxy.mjs index a9cb49529..8c4c84d5a 100644 --- a/graph-ui/tools/handtest-fixes-galaxy.mjs +++ b/graph-ui/tools/handtest-fixes-galaxy.mjs @@ -297,8 +297,76 @@ async function checkK2(page) { } /* ------------------------------------------------------------------ */ +/* K8: die dritte Ebene haengt nicht, "−" bricht ab */ -const CHECKS = { K9: checkK9, K2: checkK2 }; +const scopeState = (page) => page.locator('.atlas-graph-scope-count').first().getAttribute('data-state', { timeout: 1000 }).catch(() => null); +const rpcSince = (page, t0) => page.evaluate((from) => window.__probeRpc.filter((row) => row.t >= from), t0); +const pageNow = (page) => page.evaluate(() => Math.round(performance.now())); +const progressShown = (page) => page.locator('.atlas-graph-render-progress').first().innerText({ timeout: 500 }).catch(() => ''); + +async function checkK8(page) { + await section(page, 'K8', 'Expand to layer 3 of JSONBAgg (both directions, all edge types) completes or warns quickly; "−" cancels within 1 s'); + await open(page); + await select(page, ROOT); + await settled(page); + await expand(page); + await shot(page, 'K8', 'two-layers', `${ROOT} at 2 layers: ${await countText(page)}`); + const minus = page.getByRole('button', { name: 'Remove graph layer' }); + const expandButton = page.getByRole('button', { name: 'Expand +1' }); + const expandTitle = await expandButton.getAttribute('title'); + + // 1. Abbrechen waehrend die dritte Ebene laedt. + await expandButton.click(); + await wait(400); + const during = { status: await countText(page), state: await scopeState(page), minusDisabled: await minus.isDisabled(), minusTitle: await minus.getAttribute('title'), + progress: await progressShown(page) }; + await shot(page, 'K8', 'cancel-loading-400ms', `400 ms into layer 3: "${during.status}", "−" enabled with "${during.minusTitle}"`); + const c0 = Date.now(); + await minus.click(); + await page.waitForFunction(() => document.querySelector('.atlas-graph-scope-count')?.getAttribute('data-state') !== 'loading' + && [...document.querySelectorAll('.atlas-graph-exploration span')].some((el) => el.textContent?.trim() === '2 layers'), null, { timeout: 10000 }).catch(() => {}); + const cancelMs = Date.now() - c0; + await shot(page, 'K8', 'cancel-0ms', `Right after "−": back on 2 layers in ${cancelMs} ms`); + await wait(250); + await shot(page, 'K8', 'cancel-250ms', '250 ms after "−"'); + await wait(750); + const cancelled = { layers: await layerText(page), status: await countText(page), state: await scopeState(page), ms: cancelMs }; + await shot(page, 'K8', 'cancel-1s', `1 s after "−": ${cancelled.layers}, ${cancelled.status}`); + await check('K8', '"−" cancels a running layer and returns to the previous one within 1 s', during.state === 'loading' && during.minusDisabled === false + && cancelMs <= 1000 && cancelled.layers === '2 layers' && /^90 nodes · 201 edges/.test(cancelled.status), { expandTitle, during, cancelled }); + + // 2. Die dritte Ebene ganz laden. + await wait(1500); + const t0 = await pageNow(page); + const w0 = Date.now(); + await expandButton.click(); + const frames = []; + for (const [label, at] of [['0ms', 0], ['250ms', 250], ['1s', 1000], ['3s', 3000]]) { + const elapsed = Date.now() - w0; + if (at > elapsed) await wait(at - elapsed); + frames.push({ at: label, status: await countText(page), state: await scopeState(page), progress: await progressShown(page) }); + await shot(page, 'K8', `layer3-${label}`, `${label} after Expand: "${frames.at(-1).status}"${frames.at(-1).progress ? `, overlay "${frames.at(-1).progress}"` : ''}`); + } + await page.waitForFunction(() => ['complete', 'partial'].includes(document.querySelector('.atlas-graph-scope-count')?.getAttribute('data-state') ?? ''), null, { timeout: 180000 }).catch(() => {}); + const doneMs = Date.now() - w0; + const calls = await rpcSince(page, t0); + const queries = calls.filter((row) => row.tool === 'query_graph'); + await shot(page, 'K8', 'layer3-done', `Layer 3 finished after ${doneMs} ms with ${queries.length} query_graph calls: "${await countText(page)}"`); + await wait(2000); + const done = { ms: doneMs, status: await countText(page), state: await scopeState(page), + title: await page.locator('.atlas-graph-scope-count').first().getAttribute('title').catch(() => null), + overlayAfter2s: await progressShown(page), queryGraph: queries.length, withCursor: queries.filter((row) => row.cursor).length, + maxRows: [...new Set(queries.map((row) => row.maxRows))], layers: await layerText(page), + expandDisabled: await expandButton.isDisabled(), expandTitle: await expandButton.getAttribute('title') }; + await shot(page, 'K8', 'layer3-done-2s', `2 s later: no "Updating view" overlay (${done.overlayAfter2s ? 'still shown' : 'gone'})`); + await check('K8', 'Layer 3 completes (or stops at the render limit with a partial note) within 30 s in few large requests, and the overlay goes away', + ['complete', 'partial'].includes(done.state ?? '') && doneMs <= 30000 && done.queryGraph <= 40 && done.overlayAfter2s === '' && done.layers === '3 layers', + { frames, done }); +} + +/* ------------------------------------------------------------------ */ + +const CHECKS = { K9: checkK9, K2: checkK2, K8: checkK8 }; await mkdir(OUT, { recursive: true }); const context = await chromium.launchPersistentContext(PROFILE, {