Select a Compose file to show its declared services.
}
{reading?.key === key && !result &&
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 →',
};