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 →', };