mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-10 09:13:53 +08:00
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 <bj@techport.io>
This commit is contained in:
@@ -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(<ContainerMap project="p" active filter="" onNavigate={vi.fn()} onShowEndpoints={showEndpoints} />));
|
||||
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(<ContainerMap project="p" active filter="" onNavigate={vi.fn()} onShowEndpoints={vi.fn()} />));
|
||||
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(<ContainerMap project="p" active={false} filter="" onNavigate={vi.fn()} />));
|
||||
expect(loadContainerTopology).not.toHaveBeenCalled(); expect(loadContainerInventory).not.toHaveBeenCalled();
|
||||
|
||||
@@ -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<ProjectEntry[]>([]);
|
||||
const [selections, setSelections] = useState<ContainerSelection[]>([]);
|
||||
const [discovery, setDiscovery] = useState('Finding indexed Compose definitions…');
|
||||
const [discovery, setDiscovery] = useState<Discovery>('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) => <button key={`${evidence.project}:${evidence.path}:${evidence.line}:${index}`} onClick={() => inspect(evidence)}>
|
||||
@@ -143,7 +150,8 @@ export default function ContainerMap({ project, generation, active, filter, onNa
|
||||
<div className="spatial-controls"><nav aria-label="Service navigation"><button onClick={clearSelection}>All services</button>{focus && <span> / {servicesById.get(focus)?.name} and neighbors</span>}</nav>
|
||||
<div className="container-edge-key"><span><i className="container-call-key" />Calls</span><label><input type="checkbox" checked={configured} onChange={event => setConfigured(event.target.checked)} />Configured destinations</label>
|
||||
<label><input type="checkbox" checked={startup} onChange={event => setStartup(event.target.checked)} />Startup dependencies</label></div></div>
|
||||
{discovery && <p role="status" className="spatial-notice">{discovery}</p>}
|
||||
{discovery && <div role="status" className="spatial-notice container-discovery" data-discovery={discovery}><p>{text.serviceMap[discovery]}</p>
|
||||
{discovery === 'none' && onShowEndpoints && <button className="spatial-primary" onClick={onShowEndpoints}>{text.showEndpoints}</button>}</div>}
|
||||
{!discovery && selections.length > 0 && !selections.some(selection => selection.manifest) && <p className="spatial-notice">Select a Compose file to show its declared services.</p>}
|
||||
{reading?.key === key && !result && <p role="status" className="spatial-notice">{reading.error ?? 'Reading deployment definitions and bounded source evidence…'}</p>}
|
||||
{graph && topology && <><div className="spatial-map-layout"><div className="spatial-map">
|
||||
|
||||
@@ -6,10 +6,12 @@ const ContainerMap = lazy(() => import('./ContainerMap'));
|
||||
/** The endpoint graph remains available independently of deployment metadata. */
|
||||
export default function RoutesArchitecture(props: ComponentProps<typeof SpatialArchitecture>) {
|
||||
const [mode, setMode] = useState<'services' | 'endpoints'>('services');
|
||||
const show = (next: 'services' | 'endpoints') => { if (mode !== next) props.onSelectionEvidence?.(undefined); setMode(next); };
|
||||
return <><nav className="container-mode" aria-label="Routes perspective">
|
||||
<button aria-pressed={mode === 'services'} onClick={() => { if (mode !== 'services') props.onSelectionEvidence?.(undefined); setMode('services'); }}>Service map</button>
|
||||
<button aria-pressed={mode === 'endpoints'} onClick={() => { if (mode !== 'endpoints') props.onSelectionEvidence?.(undefined); setMode('endpoints'); }}>Endpoints</button>
|
||||
<button aria-pressed={mode === 'services'} onClick={() => show('services')}>Service map</button>
|
||||
<button aria-pressed={mode === 'endpoints'} onClick={() => show('endpoints')}>Endpoints</button>
|
||||
</nav>{mode === 'services' ? <Suspense fallback={<p role="status">Preparing service map…</p>}><ContainerMap
|
||||
project={props.project} generation={props.generation} active={props.active} filter={props.filter} onNavigate={props.onNavigate} onClearSelection={props.onClearSelection} onSelectionEvidence={props.onSelectionEvidence} /></Suspense>
|
||||
project={props.project} generation={props.generation} active={props.active} filter={props.filter} onNavigate={props.onNavigate} onClearSelection={props.onClearSelection} onSelectionEvidence={props.onSelectionEvidence}
|
||||
onShowEndpoints={() => show('endpoints')} /></Suspense>
|
||||
: <SpatialArchitecture {...props} />}</>;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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<ContainerInventory> {
|
||||
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,
|
||||
|
||||
@@ -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 →',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user