From 127db22acff91fbb141b9c32371358d03f408094 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Sat, 3 Oct 2026 19:01:20 +0200 Subject: [PATCH] fix(ui): tell a project without deployment files from a read error Routes > Service map read Django's whole File, Module and Class inventory before it looked for Compose manifests. Each query needs more than twenty result pages there, the client gives up at twenty, and the map reported "Could not read the indexed deployment files" for a project that simply has none. Manifests are now found first with a filtered query. Without one the map says that no Docker Compose deployment files were found and offers the Endpoints view; a failed read keeps its own message. A manifest outside the bounded inventory stays readable through its own identity. Signed-off-by: Bernhard Jackiewicz --- .../src/architecture/ContainerMap.test.tsx | 17 +++++++++++++ graph-ui/src/architecture/ContainerMap.tsx | 24 ++++++++++++------- .../src/architecture/RoutesArchitecture.tsx | 8 ++++--- graph-ui/src/architecture/container-map.css | 5 ++++ .../src/architecture/container-source.test.ts | 21 +++++++++++++++- graph-ui/src/architecture/container-source.ts | 13 +++++++++- graph-ui/src/architecture/strings.ts | 8 +++++++ 7 files changed, 83 insertions(+), 13 deletions(-) diff --git a/graph-ui/src/architecture/ContainerMap.test.tsx b/graph-ui/src/architecture/ContainerMap.test.tsx index 9e2c90c52..39f1ad1d3 100644 --- a/graph-ui/src/architecture/ContainerMap.test.tsx +++ b/graph-ui/src/architecture/ContainerMap.test.tsx @@ -53,6 +53,23 @@ describe('service map interactions', () => { expect(button('Open declaration')).toBeUndefined(); expect(clear).toHaveBeenCalledOnce(); }); + it('says plainly that a project has no deployment files and leads to its endpoints', async () => { + vi.mocked(loadContainerInventory).mockResolvedValue({ project: 'p', rootPath: '/p', files: new Map(), manifests: [], warnings: [] }); + const showEndpoints = vi.fn(); + await act(async () => root.render()); + expect(container.textContent).toContain('No Docker Compose deployment files were found in this index. Endpoints shows the routes.'); + expect(container.textContent).not.toContain('Could not read'); + await act(async () => button('Show endpoints →').click()); + expect(showEndpoints).toHaveBeenCalledOnce(); + expect(loadContainerTopology).not.toHaveBeenCalled(); + }); + it('keeps a failed read distinct from a project without deployment files', async () => { + vi.mocked(loadContainerInventory).mockRejectedValue(new Error('query_graph: Incomplete query results')); + await act(async () => root.render()); + expect(container.textContent).toContain('Could not read the indexed deployment files. Refresh to try again.'); + expect(container.textContent).not.toContain('No Docker Compose deployment files'); + expect(button('Show endpoints →')).toBeUndefined(); + }); it('does not request deployment source while the workspace is inactive', async () => { await act(async () => root.render()); expect(loadContainerTopology).not.toHaveBeenCalled(); expect(loadContainerInventory).not.toHaveBeenCalled(); diff --git a/graph-ui/src/architecture/ContainerMap.tsx b/graph-ui/src/architecture/ContainerMap.tsx index a61cb7b67..8d67d33b4 100644 --- a/graph-ui/src/architecture/ContainerMap.tsx +++ b/graph-ui/src/architecture/ContainerMap.tsx @@ -6,10 +6,17 @@ import { layoutContainers } from './container-layout'; import { loadContainerInventory, loadContainerTopology, type ContainerReading, type ContainerSelection } from './container-source'; import type { ServiceEvidence } from './container-topology'; import { useSelectionEvidence, type SelectionEvidenceListener } from '../galaxy/selection-evidence'; +import { architectureText as text } from './strings'; import './spatial-architecture.css'; import './container-map.css'; -interface Props { project: string; generation?: string; active: boolean; filter: string; onNavigate: (path: string, line?: number) => void; onClearSelection?: () => void; onSelectionEvidence?: SelectionEvidenceListener } +interface Props { + project: string; generation?: string; active: boolean; filter: string; onNavigate: (path: string, line?: number) => void; onClearSelection?: () => void; onSelectionEvidence?: SelectionEvidenceListener; + /** Leads from a project without deployment files to the routes it does have. */ + onShowEndpoints?: () => void; +} +/** Reading the deployment inventory: a project without Compose files is an answer, not a failure. */ +type Discovery = keyof typeof text.serviceMap | undefined; class ContainerSceneBoundary extends Component<{ children: ReactNode }, { failed: boolean }> { state = { failed: false }; static getDerivedStateFromError() { return { failed: true }; } @@ -21,10 +28,10 @@ const relativeSource = (absolute: string, selection: ContainerSelection) => { return absolute.startsWith(prefix) ? absolute.slice(prefix.length) : undefined; }; -export default function ContainerMap({ project, generation, active, filter, onNavigate, onClearSelection, onSelectionEvidence }: Props) { +export default function ContainerMap({ project, generation, active, filter, onNavigate, onClearSelection, onSelectionEvidence, onShowEndpoints }: Props) { const [available, setAvailable] = useState([]); const [selections, setSelections] = useState([]); - const [discovery, setDiscovery] = useState('Finding indexed Compose definitions…'); + const [discovery, setDiscovery] = useState('reading'); const [reading, setReading] = useState<{ key: string; result?: ContainerReading; error?: string }>(); const [revision, setRevision] = useState(0); const [discoveryRevision, setDiscoveryRevision] = useState(0); @@ -40,7 +47,7 @@ export default function ContainerMap({ project, generation, active, filter, onNa useEffect(() => { if (!active) return; const controller = new AbortController(), client = new RpcIntelligenceClient({ signal: controller.signal }); - setDiscovery('Finding indexed Compose definitions…'); + setDiscovery('reading'); void client.listProjects().then(async response => { const projects = response.projects; const current = projects.find(entry => entry.name === project); @@ -48,8 +55,8 @@ export default function ContainerMap({ project, generation, active, filter, onNa const inventory = await loadContainerInventory(current, client); if (controller.signal.aborted) return; setAvailable(projects); setSelections([{ inventory, manifest: inventory.manifests[0] ?? '' }]); - setDiscovery(inventory.manifests.length ? '' : 'No indexed Compose file was found. The Endpoints view is still available.'); - }).catch(() => { if (!controller.signal.aborted) setDiscovery('Could not read the indexed deployment files. Refresh to try again.'); }); + setDiscovery(inventory.manifests.length ? undefined : 'none'); + }).catch(() => { if (!controller.signal.aborted) setDiscovery('failed'); }); return () => controller.abort(); }, [project, active, generation, discoveryRevision]); const selectionKey = JSON.stringify(selections.map(item => [item.inventory.project, item.manifest])); @@ -107,7 +114,7 @@ export default function ContainerMap({ project, generation, active, filter, onNa try { const inventory = await loadContainerInventory(entry, new RpcIntelligenceClient()); setSelections(current => current.some(item => item.inventory.project === name) ? current : [...current, { inventory, manifest: inventory.manifests[0] ?? '' }]); - } catch { setDiscovery('Could not read that project. Its index may be unavailable.'); } + } catch { setDiscovery('compareFailed'); } finally { setAdding(false); } }; const evidenceButton = (evidence: ServiceEvidence, index: number) => {focus && / {servicesById.get(focus)?.name} and neighbors}
Calls
- {discovery &&

{discovery}

} + {discovery &&

{text.serviceMap[discovery]}

+ {discovery === 'none' && onShowEndpoints && }
} {!discovery && selections.length > 0 && !selections.some(selection => selection.manifest) &&

Select a Compose file to show its declared services.

} {reading?.key === key && !result &&

{reading.error ?? 'Reading deployment definitions and bounded source evidence…'}

} {graph && topology && <>
diff --git a/graph-ui/src/architecture/RoutesArchitecture.tsx b/graph-ui/src/architecture/RoutesArchitecture.tsx index 3cfc4ed4e..9beb85fe4 100644 --- a/graph-ui/src/architecture/RoutesArchitecture.tsx +++ b/graph-ui/src/architecture/RoutesArchitecture.tsx @@ -6,10 +6,12 @@ const ContainerMap = lazy(() => import('./ContainerMap')); /** The endpoint graph remains available independently of deployment metadata. */ export default function RoutesArchitecture(props: ComponentProps) { const [mode, setMode] = useState<'services' | 'endpoints'>('services'); + const show = (next: 'services' | 'endpoints') => { if (mode !== next) props.onSelectionEvidence?.(undefined); setMode(next); }; return <>{mode === 'services' ? Preparing service map…

}>
+ project={props.project} generation={props.generation} active={props.active} filter={props.filter} onNavigate={props.onNavigate} onClearSelection={props.onClearSelection} onSelectionEvidence={props.onSelectionEvidence} + onShowEndpoints={() => show('endpoints')} /> : }; } diff --git a/graph-ui/src/architecture/container-map.css b/graph-ui/src/architecture/container-map.css index e70dfb1c9..3cdb08415 100644 --- a/graph-ui/src/architecture/container-map.css +++ b/graph-ui/src/architecture/container-map.css @@ -20,3 +20,8 @@ .container-mode button[aria-pressed=true] { color:#d9ece5; background:#243d35; border-color:#638d7b; } .container-mode button:focus-visible { outline:2px solid #8ce9f7; outline-offset:3px; } @media(max-width:760px) { .container-projects { padding:12px; } .container-map .spatial-map { height:540px; } } +/* A project without Compose files is an answer, not a warning; a read failure keeps the warning tone. */ +.container-discovery { display:flex; flex-wrap:wrap; align-items:center; gap:10px 16px; padding:12px 20px; } +.container-discovery p { margin:0; } +.container-discovery[data-discovery=none], .container-discovery[data-discovery=reading] { color:var(--spatial-muted); font-size:12px; } +.container-discovery .spatial-primary { border-color:#449e7b; background:#16322b; color:#90f0c9; } diff --git a/graph-ui/src/architecture/container-source.test.ts b/graph-ui/src/architecture/container-source.test.ts index 0152c478c..8cde83d18 100644 --- a/graph-ui/src/architecture/container-source.test.ts +++ b/graph-ui/src/architecture/container-source.test.ts @@ -21,6 +21,24 @@ describe('container source inspection', () => { expect(result.files.size).toBe(4000); expect(result.manifests).toEqual(['compose.yml']); expect(result.warnings).toHaveLength(1); expect(queryRows.mock.calls[0][0]).toBe('p'); }); + it('finds no deployment files in a large repository instead of failing on its whole file inventory', async () => { + // Django: more File, Module and Class rows than the client pages through, and no Compose file. + const queryRows = vi.fn(async (_project: string, query: string) => { + if (query.includes('=~')) return [{ path: 'docs/compose-notes.txt', qn: 'notes' }]; + throw new Error('query_graph: Incomplete query results: retrieval bound reached (4200 rows read).'); + }); + const result = await loadContainerInventory({ name: 'django', root_path: '/django' }, client({ queryRows })); + expect(result.manifests).toEqual([]); + expect(queryRows).toHaveBeenCalledTimes(1); + }); + it('keeps a discovered manifest readable even beyond the bounded file inventory', async () => { + const queryRows = vi.fn(async (_project: string, query: string) => query.includes('=~') + ? [{ path: 'deploy/docker-compose.yml', qn: 'demo.deploy.compose' }] + : query.includes('f:File') ? [{ path: 'api/main.py', qn: 'demo.main' }] : []); + const result = await loadContainerInventory({ name: 'p', root_path: '/p' }, client({ queryRows })); + expect(result.manifests).toEqual(['deploy/docker-compose.yml']); + expect(result.files.get('deploy/docker-compose.yml')).toBe('demo.deploy.compose'); + }); it('rejects incomplete YAML instead of constructing a partial service declaration', async () => { const getCodeSnippet = vi.fn(async () => ({ source: 'services:\n web:', start_line: 1, source_truncated: true })); await expect(readContainerSource(inventory, 'compose.yml', client({ getCodeSnippet }), signal())).rejects.toThrow('incomplete'); @@ -43,7 +61,8 @@ describe('container source inspection', () => { expect(getCodeSnippet).toHaveBeenCalledTimes(1); }); it('reads complete indexed class ranges when a whole-file module is unavailable, preserving source lines', async () => { - const queryRows = vi.fn(async (_project: string, query: string) => query.includes('f:File') + // The source inventory is read for a project that declares services. + const queryRows = vi.fn(async (_project: string, query: string) => query.includes('=~') ? [{ path: 'compose.yml', qn: 'compose' }] : query.includes('f:File') ? [{ path: 'api/Client.java', qn: 'file' }] : query.includes('f:Class') ? [ { path: 'api/Client.java', qn: 'outer', first_line: '5', last_line: '8' }, diff --git a/graph-ui/src/architecture/container-source.ts b/graph-ui/src/architecture/container-source.ts index cacb490a1..af208c5f1 100644 --- a/graph-ui/src/architecture/container-source.ts +++ b/graph-ui/src/architecture/container-source.ts @@ -18,8 +18,17 @@ const isCode = (path: string) => /\.(?:py|js|jsx|ts|tsx|cs|go|java|kt|rs|rb|php| && !/\.(?:min|test|spec)\.[^.]+$/.test(path); const checkAbort = (signal: AbortSignal) => { if (signal.aborted) throw new DOMException('Aborted', 'AbortError'); }; -/** Queries only indexed files. The caller explicitly selects each additional project. */ +/** File names that can be Compose manifests; isComposePath decides. The filter keeps the answer small in any repository. */ +const COMPOSE_CANDIDATES = 'MATCH (f:File) WHERE f.file_path =~ "[Cc][Oo][Mm][Pp][Oo][Ss][Ee][^/]*[.][Yy][Aa]?[Mm][Ll]$" RETURN f.file_path AS path, f.qualified_name AS qn ORDER BY f.file_path LIMIT 201'; + +/** + * Queries only indexed files. The caller explicitly selects each additional project. + * Manifests are found first with a filtered query: a repository without one (Django) + * then reads as "no deployment files" and never pages through its whole inventory. + */ export async function loadContainerInventory(project: ProjectEntry, client: RpcIntelligenceClient): Promise { + const candidates = (await client.queryRows(project.name, COMPOSE_CANDIDATES)).filter(row => row.path && row.qn && isComposePath(row.path)); + if (!candidates.length) return { project: project.name, rootPath: project.root_path ?? '', files: new Map(), manifests: [], warnings: [] }; const [rows, modules, classes, interfaces] = await Promise.all([ client.queryRows(project.name, 'MATCH (f:File) RETURN f.file_path AS path, f.qualified_name AS qn ORDER BY f.file_path LIMIT 4001'), client.queryRows(project.name, 'MATCH (f:Module) RETURN f.file_path AS path, f.qualified_name AS qn, f.start_line AS first_line, f.end_line AS last_line ORDER BY f.file_path LIMIT 4001'), @@ -50,6 +59,8 @@ export async function loadContainerInventory(project: ProjectEntry, client: RpcI } sourceSpans.set(path, outer); } + // A manifest beyond the bounded inventory stays readable through its own identity. + for (const row of candidates) if (!files.has(row.path)) files.set(row.path, row.qn); const manifests = [...files.keys()].filter(isComposePath).sort((a, b) => Number(!/^(?:docker-)?compose\.ya?ml$/.test(a)) - Number(!/^(?:docker-)?compose\.ya?ml$/.test(b)) || a.localeCompare(b)); return { project: project.name, rootPath: project.root_path ?? '', files, manifests, sourceEnds, sourceSpans, diff --git a/graph-ui/src/architecture/strings.ts b/graph-ui/src/architecture/strings.ts index dca30f920..ce77bdc3c 100644 --- a/graph-ui/src/architecture/strings.ts +++ b/graph-ui/src/architecture/strings.ts @@ -80,4 +80,12 @@ export const architectureText = { behaviorLimited: 'The analysis returned no call evidence.', includeTestRoutes: (hidden: number) => hidden ? `Include test routes (${hidden.toLocaleString()} hidden)` : 'Include test routes', showRoutes: (count: number) => `Show these ${count.toLocaleString()} routes →`, + /** Routes › Service map: what the deployment inventory read found. */ + serviceMap: { + reading: 'Finding indexed Compose definitions…', + none: 'No Docker Compose deployment files were found in this index. Endpoints shows the routes.', + failed: 'Could not read the indexed deployment files. Refresh to try again.', + compareFailed: 'Could not read that project. Its index may be unavailable.', + }, + showEndpoints: 'Show endpoints →', };