diff --git a/frontend/src/components/NetworkGraph.map-lookup.test.ts b/frontend/src/components/NetworkGraph.map-lookup.test.ts
index 3ba76c75c..71f718a31 100644
--- a/frontend/src/components/NetworkGraph.map-lookup.test.ts
+++ b/frontend/src/components/NetworkGraph.map-lookup.test.ts
@@ -18,7 +18,7 @@ function sourceBetween(startMarker: string, endMarker: string): string {
return networkGraphSource.slice(startIndex, endIndex);
}
-describe("NetworkGraph constant-time selection lookup contract", () => {
+describe("NetworkGraph indexed lookup architecture", () => {
it("keeps graph event selection on memoized maps without linear fallback scans", () => {
const edgeSelection = sourceBetween("const selectEdge =", "const selectNode =");
const nodeSelection = sourceBetween("const selectNode =", "const handleEdgeSelection =");
diff --git a/frontend/src/components/NetworkGraph.option-limits.test.tsx b/frontend/src/components/NetworkGraph.option-limits.test.tsx
new file mode 100644
index 000000000..e69a4089c
--- /dev/null
+++ b/frontend/src/components/NetworkGraph.option-limits.test.tsx
@@ -0,0 +1,129 @@
+/* @vitest-environment jsdom */
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+const destroyMock = vi.fn();
+
+vi.mock("vis-network", () => ({
+ Network: vi.fn(function MockNetwork() {
+ return {
+ destroy: destroyMock,
+ fit: vi.fn(),
+ moveTo: vi.fn(),
+ off: vi.fn(),
+ on: vi.fn(),
+ selectEdges: vi.fn(),
+ selectNodes: vi.fn(),
+ };
+ }),
+}));
+
+import NetworkGraph from "./NetworkGraph";
+
+function jsonResponse(body: unknown) {
+ return {
+ ok: true,
+ json: async () => body,
+ };
+}
+
+async function flushAsyncWork() {
+ for (let index = 0; index < 5; index += 1) {
+ await act(async () => {
+ await Promise.resolve();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ }
+}
+
+describe("NetworkGraph display limits", () => {
+ let root: Root | null = null;
+ let container: HTMLDivElement | null = null;
+
+ afterEach(() => {
+ if (root) {
+ act(() => root?.unmount());
+ }
+ root = null;
+ container?.remove();
+ container = null;
+ vi.unstubAllGlobals();
+ vi.clearAllMocks();
+ });
+
+ it("renders bounded options while preserving the first five non-empty node labels", async () => {
+ const nodes = [
+ { id: "blank-1", label: "" },
+ { id: "node-1", label: "노드 1" },
+ { id: "blank-2", label: "" },
+ { id: "node-2", label: "노드 2" },
+ { id: "node-3", label: "노드 3" },
+ { id: "node-4", label: "노드 4" },
+ { id: "node-5", label: "노드 5" },
+ { id: "node-6", label: "노드 6" },
+ { id: "node-7", label: "노드 7" },
+ { id: "node-8", label: "노드 8" },
+ ];
+ const edges = Array.from({ length: 7 }, (_, index) => ({
+ id: `edge-${index + 1}`,
+ from: "node-1",
+ to: "node-2",
+ title: `관계 ${index + 1}`,
+ }));
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => Promise.resolve(jsonResponse({ nodes, edges }))),
+ );
+
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+
+ await act(async () => {
+ root?.render();
+ });
+ await flushAsyncWork();
+
+ const relationshipSelect = container.querySelector(
+ 'select[aria-label="관계 선택"]',
+ );
+ const nodeSelect = container.querySelector(
+ 'select[aria-label="노드 선택"]',
+ );
+ const relationshipValues = Array.from(relationshipSelect?.options ?? []).map(
+ (option) => option.value,
+ );
+ const nodeValues = Array.from(nodeSelect?.options ?? []).map(
+ (option) => option.value,
+ );
+
+ expect(relationshipValues).toEqual([
+ "",
+ "edge-1",
+ "edge-2",
+ "edge-3",
+ "edge-4",
+ "edge-5",
+ ]);
+ expect(nodeValues).toEqual([
+ "",
+ "blank-1",
+ "node-1",
+ "blank-2",
+ "node-2",
+ "node-3",
+ "node-4",
+ "node-5",
+ "node-6",
+ ]);
+
+ const summary = Array.from(container.querySelectorAll("p")).find((element) =>
+ element.textContent?.includes("관련 노드:"),
+ );
+ expect(summary?.textContent).toContain(
+ "관련 노드: 노드 1, 노드 2, 노드 3, 노드 4, 노드 5",
+ );
+ expect(summary?.textContent).not.toContain("노드 6");
+ });
+});
diff --git a/frontend/src/components/NetworkGraph.tsx b/frontend/src/components/NetworkGraph.tsx
index f9eb61c71..77bafeb3c 100644
--- a/frontend/src/components/NetworkGraph.tsx
+++ b/frontend/src/components/NetworkGraph.tsx
@@ -278,27 +278,44 @@ export default function NetworkGraph() {
}, [nodes, edges, nodeMap, edgeMap]);
const nodeLabels = useMemo(() => {
- return nodes
- .map((node) => String(node.label ?? node.id))
- .filter(Boolean)
- .slice(0, 5);
+ const labels = [];
+ for (const node of nodes) {
+ if (labels.length >= 5) break;
+ const label = String(node.label ?? node.id);
+ if (label) labels.push(label);
+ }
+ return labels;
}, [nodes]);
const firstEdge = edges[0] ?? null;
const relationshipOptions = useMemo(() => {
- return Array.from(edgeMap.values()).slice(0, 5).map((edge, index) => ({
- edge,
- id: String(edge.id),
- label: `관계 ${index + 1}: ${describeEdge(edge, nodeMap)}`,
- }));
+ const options = [];
+ let index = 0;
+ for (const edge of edgeMap.values()) {
+ if (index >= 5) break;
+ options.push({
+ edge,
+ id: String(edge.id),
+ label: `관계 ${index + 1}: ${describeEdge(edge, nodeMap)}`,
+ });
+ index++;
+ }
+ return options;
}, [edgeMap, nodeMap]);
const nodeOptions = useMemo(() => {
- return Array.from(nodeInstanceMap.values()).slice(0, 8).map((node) => ({
- id: String(node.id),
- label: `노드: ${String(node.label ?? node.id)}`,
- node,
- }));
+ const options = [];
+ let count = 0;
+ for (const node of nodeInstanceMap.values()) {
+ if (count >= 8) break;
+ options.push({
+ id: String(node.id),
+ label: `노드: ${String(node.label ?? node.id)}`,
+ node,
+ });
+ count++;
+ }
+ return options;
}, [nodeInstanceMap]);
const selectRelationship = (edge: Edge, status: string) => {
diff --git a/requirements-strix-ci-hashes.txt b/requirements-strix-ci-hashes.txt
index eaa2ad04f..268d89992 100644
--- a/requirements-strix-ci-hashes.txt
+++ b/requirements-strix-ci-hashes.txt
@@ -142,6 +142,7 @@ anyio==4.14.2 \
# google-genai
# gql
# httpx
+ # httpx2
# mcp
# openai
# sse-starlette
@@ -849,6 +850,7 @@ h11==0.16.0 \
--hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
# via
# httpcore
+ # httpcore2
# uvicorn
hf-xet==1.5.1 \
--hash=sha256:0c97106032ef70467b4f6bc2d0ccc266d7613ee076afc56516c502f87ce1c4a6 \
@@ -881,6 +883,10 @@ httpcore==1.0.9 \
--hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \
--hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8
# via httpx
+httpcore2==2.12.0 \
+ --hash=sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb \
+ --hash=sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648
+ # via httpx2
httpx==0.28.1 \
--hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
--hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
@@ -894,6 +900,10 @@ httpx-sse==0.4.3 \
--hash=sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc \
--hash=sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d
# via mcp
+httpx2==2.12.0 \
+ --hash=sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf \
+ --hash=sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36
+ # via -r requirements-strix-ci.txt
huggingface-hub==1.23.0 \
--hash=sha256:b1d604788f5adc7f0eb246e03e0ec19011ca06e38400218c347dccc3dffa64a2 \
--hash=sha256:c04997fb8bbdace1e57b7703d30ed7678af51f70d00d241819ff411b92ae9a88
@@ -904,6 +914,7 @@ idna==3.18 \
# via
# anyio
# httpx
+ # httpx2
# requests
# yarl
importlib-metadata==8.9.0 \
@@ -2103,6 +2114,12 @@ tqdm==4.68.4 \
# via
# huggingface-hub
# openai
+truststore==0.10.4 \
+ --hash=sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301 \
+ --hash=sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981
+ # via
+ # httpcore2
+ # httpx2
types-requests==2.33.0.20260712 \
--hash=sha256:2141b67ab534a5c5cd2dac5034f2a35f42e699c5bf185eee608c5246a069d7fb \
--hash=sha256:de027e28c171d3da529689cbfa023b0b4eab188c8dfa22fd834eebd2cee6e7bb
diff --git a/requirements-strix-ci.txt b/requirements-strix-ci.txt
index a1faaf6a4..58506695d 100644
--- a/requirements-strix-ci.txt
+++ b/requirements-strix-ci.txt
@@ -3,3 +3,4 @@ google-cloud-aiplatform==1.160.0
cryptography==50.0.0
protobuf==6.33.6
python-multipart==0.0.32
+httpx2