Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

62 changes: 62 additions & 0 deletions src/loadpath/static/assets/index-CPytLSOG.js

Large diffs are not rendered by default.

62 changes: 0 additions & 62 deletions src/loadpath/static/assets/index-_7_dtN73.js

This file was deleted.

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/loadpath/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<script type="module" crossorigin src="./assets/index-_7_dtN73.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BSKSsotG.css">
<script type="module" crossorigin src="./assets/index-CPytLSOG.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-lBf9fQrf.css">
</head>
<body>
<div id="root"></div>
Expand Down
3 changes: 3 additions & 0 deletions tests/e2e/test_ui_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ def test_ui_index_review_graph_copy_and_workspace(live_app, browser_page):
inspector = page.get_by_test_id("graph-inspector")
inspector.wait_for(timeout=10_000)
assert inspector.inner_text().strip()
assert page.get_by_test_id("graph-inspector-purpose").inner_text().strip()
assert page.get_by_test_id("graph-inspector-inputs").is_visible()
assert page.get_by_test_id("graph-inspector-outputs").is_visible()
overflow = inspector.evaluate(
"""el => {
const pane = el.parentElement;
Expand Down
134 changes: 123 additions & 11 deletions ui/src/ImpactGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
type GraphFamily,
type GraphProjection,
} from "./graphView";
import { inspectNode, type InspectorLink } from "./nodeInspector";
import { layoutNodes, type GraphEdge, type GraphNode } from "./types";

const LayeredGraph3D = lazy(() =>
Expand Down Expand Up @@ -100,22 +101,124 @@ export function toReactFlowElements(
return { rfNodes, rfEdges };
}

function GraphInspector({ node }: { node: GraphNode }) {
function GraphInspector({
node,
nodes,
edges,
onClose,
}: {
node: GraphNode;
nodes: GraphNode[];
edges: GraphEdge[];
onClose: () => void;
}) {
const info = inspectNode(node, nodes, edges);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<aside className="inspector" data-testid="graph-inspector">
<div className="t">{typeLabel(node.type)}</div>
<div className="n">{wrapHint(node.name)}</div>
{node.context ? <div className="muted">{wrapHint(node.context)}</div> : null}
{node.file_path ? (
<div className="file">
{wrapHint(`${node.file_path}${node.start_line ? `:${node.start_line}` : ""}`)}
<div className="inspector-head">
<div className="t">{info.typeLabel}</div>
<div className="inspector-roles">
{info.roles.map((role) => (
<span key={role} className="inspector-chip">
{role}
</span>
))}
</div>
<button
type="button"
className="inspector-close"
data-testid="graph-inspector-close"
aria-label="Close inspector"
onClick={onClose}
>
×
</button>
</div>
<div className="n">{wrapHint(info.name)}</div>
<p className="inspector-purpose" data-testid="graph-inspector-purpose">
{info.purpose}
</p>
{info.context ? <div className="muted">{wrapHint(info.context)}</div> : null}
{info.file ? <div className="file">{wrapHint(info.file)}</div> : null}
<div className="muted">{wrapHint(info.qualifiedName)}</div>
<div className="muted inspector-layer">layer · {info.layer}</div>
{info.facts.length ? (
<dl className="inspector-facts" data-testid="graph-inspector-facts">
{info.facts.map((fact) => (
<div key={fact.key} className="inspector-fact">
<dt>{fact.label}</dt>
<dd>{wrapHint(fact.value)}</dd>
</div>
))}
</dl>
) : null}
<div className="muted">{wrapHint(node.qualified_name)}</div>
<InspectorLinks
title="Inputs"
testId="graph-inspector-inputs"
links={info.inputs}
extra={info.extraInputs}
empty="Nothing in this graph points here."
/>
<InspectorLinks
title="Outputs"
testId="graph-inspector-outputs"
links={info.outputs}
extra={info.extraOutputs}
empty="This node does not point at anything in this graph."
/>
</aside>
);
}

function InspectorLinks({
title,
testId,
links,
extra,
empty,
}: {
title: string;
testId: string;
links: InspectorLink[];
extra: number;
empty: string;
}) {
return (
<section className="inspector-section" data-testid={testId}>
<h3>
{title}
<span className="count">{links.length + extra}</span>
</h3>
{links.length ? (
<ul>
{links.map((link, i) => (
<li key={`${link.edgeType}:${link.id}:${i}`}>
<span className="inspector-link-name" title={link.name}>
{wrapHint(link.name)}
</span>
<span className="inspector-link-meta">
{link.typeLabel ? `${link.typeLabel} · ` : ""}
{link.edgeLabel}
{link.inferred ? " · inferred" : ""}
</span>
</li>
))}
</ul>
) : (
<p className="muted">{empty}</p>
)}
{extra ? <p className="muted">+{extra} more</p> : null}
</section>
);
}

export function ImpactGraph({ nodes, edges }: { nodes: GraphNode[]; edges: GraphEdge[] }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [projection, setProjection] = useState<GraphProjection | null>(null);
Expand Down Expand Up @@ -155,6 +258,11 @@ export function ImpactGraph({ nodes, edges }: { nodes: GraphNode[]; edges: Graph
setSelectedId(node.id);
};

const clearSelection = () => {
setSelectedId(null);
setNeighborhoodOnly(false);
};

const toggleFamily = (family: GraphFamily) => {
setFamilies((current) => {
const next = new Set(current);
Expand Down Expand Up @@ -270,7 +378,9 @@ export function ImpactGraph({ nodes, edges }: { nodes: GraphNode[]; edges: Graph
}}
/>
</Suspense>
{selected ? <GraphInspector node={selected} /> : null}
{selected ? (
<GraphInspector node={selected} nodes={nodes} edges={edges} onClose={clearSelection} />
) : null}
</div>
) : (
<ReactFlowProvider>
Expand All @@ -286,7 +396,7 @@ export function ImpactGraph({ nodes, edges }: { nodes: GraphNode[]; edges: Graph
elementsSelectable
deleteKeyCode={null}
onNodeClick={onNodeClick}
onPaneClick={() => setSelectedId(null)}
onPaneClick={clearSelection}
proOptions={{ hideAttribution: false }}
data-testid="impact-graph"
>
Expand All @@ -306,7 +416,9 @@ export function ImpactGraph({ nodes, edges }: { nodes: GraphNode[]; edges: Graph
/>
<Controls />
</ReactFlow>
{selected ? <GraphInspector node={selected} /> : null}
{selected ? (
<GraphInspector node={selected} nodes={nodes} edges={edges} onClose={clearSelection} />
) : null}
</ReactFlowProvider>
)}
</div>
Expand Down
167 changes: 167 additions & 0 deletions ui/src/nodeInspector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { describe, expect, it } from "vitest";
import { factsFromExtra, inspectNode, typePurpose } from "./nodeInspector";
import type { GraphEdge, GraphNode } from "./types";

function node(partial: Partial<GraphNode> & Pick<GraphNode, "id" | "type" | "name">): GraphNode {
return {
qualified_name: partial.qualified_name ?? partial.name,
...partial,
};
}

describe("typePurpose", () => {
it("explains known types and falls back by family", () => {
expect(typePurpose("django.field")).toMatch(/column/i);
expect(typePurpose("react.form_schema")).toMatch(/typed/i);
expect(typePurpose("django.mystery")).toMatch(/Django/);
});
});

describe("factsFromExtra", () => {
it("surfaces typed field metadata and skips noise", () => {
const facts = factsFromExtra({
field_type: "DecimalField",
unique: true,
db_index: false,
referenced: true,
on_delete: "CASCADE",
related_name: "invoices",
});
expect(facts.map((f) => f.label)).toEqual(["Type", "on_delete", "related_name", "Unique"]);
expect(facts[0].value).toBe("DecimalField");
expect(facts.find((f) => f.key === "unique")?.value).toBe("yes");
});

it("keeps a false idempotency flag and joins field lists", () => {
const facts = factsFromExtra({
looks_idempotent_on_pk: false,
fields: ["id", "total", "status"],
placeholder: true,
});
expect(facts).toEqual([
{ key: "fields", label: "Fields", value: "id, total, status" },
{ key: "looks_idempotent_on_pk", label: "Idempotent on pk", value: "no" },
]);
});

it("does not repeat role chips as facts", () => {
const facts = factsFromExtra({
generated: true,
inferred: true,
mutation: true,
fbv: true,
ninja: true,
method: "GET",
});
expect(facts.map((f) => f.key)).toEqual(["method"]);
});
});

describe("inspectNode", () => {
const view = node({
id: "django.view:billing.InvoiceViewSet",
type: "django.view",
name: "InvoiceViewSet",
qualified_name: "billing.InvoiceViewSet",
file_path: "backend/billing/views.py",
start_line: 12,
context: "billing",
extra: {
app: "billing",
bases: ["ModelViewSet"],
permissions: ["IsAuthenticated"],
},
});
const ser = node({
id: "django.serializer:billing.InvoiceSerializer",
type: "django.serializer",
name: "InvoiceSerializer",
});
const route = node({
id: "django.route:billing:/api/invoices/{id}",
type: "django.route",
name: "/api/invoices/{id}",
});
const ghost = {
id: "ghost",
src: view.id,
dst: "django.model:billing.Missing",
type: "queries_model",
weight: "expensive",
confidence: 0.6,
} satisfies GraphEdge;
const uses = {
id: "uses",
src: view.id,
dst: ser.id,
type: "uses_serializer",
weight: "expensive",
confidence: 1,
} satisfies GraphEdge;
const publishes = {
id: "pub",
src: route.id,
dst: view.id,
type: "publishes_route",
weight: "critical",
confidence: 1,
} satisfies GraphEdge;

it("builds purpose, typed facts, and input/output neighbors", () => {
const info = inspectNode(view, [view, ser, route], [uses, publishes, ghost]);
expect(info.typeLabel).toBe("view");
expect(info.layer).toBe("views");
expect(info.purpose).toMatch(/Request handler/);
expect(info.roles).toEqual([]);
expect(info.file).toBe("backend/billing/views.py:12");
expect(info.facts.map((f) => `${f.label}: ${f.value}`)).toEqual([
"Extends: ModelViewSet",
"Permissions: IsAuthenticated",
]);
expect(info.inputs).toEqual([
expect.objectContaining({
name: "/api/invoices/{id}",
typeLabel: "route",
edgeLabel: "publishes route",
inferred: false,
}),
]);
expect(info.outputs.map((l) => l.name)).toEqual(["InvoiceSerializer", "billing.Missing"]);
expect(info.outputs[1].inferred).toBe(true);
expect(info.outputs[1].typeLabel).toBe("");
});

it("tags sinks and contracts", () => {
const info = inspectNode(route, [route], []);
expect(info.roles).toEqual(["sink", "contract"]);
});

it("keeps app when it is not the bounded context", () => {
const info = inspectNode(
node({
id: "django.view:billing.InvoiceViewSet",
type: "django.view",
name: "InvoiceViewSet",
context: "commerce",
extra: { app: "billing" },
}),
[],
[],
);
expect(info.facts).toEqual([{ key: "app", label: "App", value: "billing" }]);
});

it("caps long neighbor lists", () => {
const fieldEdges: GraphEdge[] = Array.from({ length: 20 }, (_, i) => ({
id: `f${i}`,
src: view.id,
dst: `django.field:billing.Invoice.f${i}`,
type: "has_field",
weight: "cheap",
confidence: 1,
}));
const info = inspectNode(view, [view], fieldEdges);
expect(info.outputs).toHaveLength(16);
expect(info.extraOutputs).toBe(4);
});
});
Loading
Loading