diff --git a/graph-ui/src/App.tsx b/graph-ui/src/App.tsx index be88d23aa..732a9d18a 100644 --- a/graph-ui/src/App.tsx +++ b/graph-ui/src/App.tsx @@ -3,16 +3,17 @@ import { GraphTab } from "./components/GraphTab"; import { StatsTab } from "./components/StatsTab"; import { ControlTab } from "./components/ControlTab"; import type { TabId } from "./lib/types"; - -const TABS: { id: TabId; label: string }[] = [ - { id: "graph", label: "Graph" }, - { id: "stats", label: "Projects" }, - { id: "control", label: "Control" }, -]; +import { useUiMessages } from "./lib/i18n"; export function App() { + const t = useUiMessages(); const [activeTab, setActiveTab] = useState("stats"); const [selectedProject, setSelectedProject] = useState(null); + const tabs: { id: TabId; label: string }[] = [ + { id: "graph", label: t.tabs.graph }, + { id: "stats", label: t.tabs.projects }, + { id: "control", label: t.tabs.control }, + ]; return (
@@ -28,17 +29,17 @@ export function App() { {/* Tabs inline in header */} @@ -46,7 +47,9 @@ export function App() { {selectedProject && (
- Graph + + {t.graph.selectedLabel} + {selectedProject} diff --git a/graph-ui/src/components/ControlTab.tsx b/graph-ui/src/components/ControlTab.tsx index f3febc79a..2d2ce103b 100644 --- a/graph-ui/src/components/ControlTab.tsx +++ b/graph-ui/src/components/ControlTab.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from "react"; import { ScrollArea } from "@/components/ui/scroll-area"; import type { ProcessInfo } from "../lib/types"; +import { useUiMessages } from "../lib/i18n"; /* ── Gauge component ────────────────────────────────────── */ @@ -30,6 +31,7 @@ function ProcessCard({ proc, selected, onSelect, onKill }: { proc: ProcessInfo; selected: boolean; onSelect: () => void; onKill: () => void; }) { + const t = useUiMessages(); return (
{!proc.is_self && ( @@ -54,7 +56,7 @@ function ProcessCard({ proc, selected, onSelect, onKill }: { onClick={(e) => { e.stopPropagation(); onKill(); }} className="px-2 py-1 rounded-lg text-[10px] text-foreground/20 hover:text-destructive hover:bg-destructive/10 transition-all" > - Kill + {t.control.kill} )}
@@ -69,7 +71,7 @@ function ProcessCard({ proc, selected, onSelect, onKill }: {

{proc.rss_mb.toFixed(0)} MB

-

Uptime

+

{t.control.uptime}

{proc.elapsed}

@@ -82,6 +84,7 @@ function ProcessCard({ proc, selected, onSelect, onKill }: { /* ── Log viewer ─────────────────────────────────────────── */ function LogViewer() { + const t = useUiMessages(); const [lines, setLines] = useState([]); useEffect(() => { @@ -100,13 +103,13 @@ function LogViewer() { return (
- Process Logs + {t.control.processLogs} {lines.length} lines
{lines.length === 0 ? ( -

No logs yet

+

{t.control.noLogs}

) : ( lines.map((line, i) => { const isErr = line.includes("level=error"); @@ -132,6 +135,7 @@ function LogViewer() { /* ── Main Control Tab ───────────────────────────────────── */ export function ControlTab() { + const t = useUiMessages(); const [processes, setProcesses] = useState([]); const [selfMetrics, setSelfMetrics] = useState({ rss_mb: 0, user_cpu: 0, sys_cpu: 0 }); const [selectedPid, setSelectedPid] = useState(null); @@ -156,7 +160,7 @@ export function ControlTab() { }, [fetchProcesses]); const killProcess = useCallback(async (pid: number) => { - if (!confirm(`Kill process ${pid}?`)) return; + if (!confirm(t.control.killConfirm(pid))) return; try { await fetch("/api/process-kill", { method: "POST", @@ -165,7 +169,7 @@ export function ControlTab() { }); setTimeout(fetchProcesses, 1000); } catch { /* ignore */ } - }, [fetchProcesses]); + }, [fetchProcesses, t.control]); /* Aggregates */ const totalCpu = processes.reduce((s, p) => s + p.cpu, 0); @@ -174,32 +178,32 @@ export function ControlTab() { return (
-

Control Panel

+

{t.control.panel}

{/* Aggregate gauges */}
- - - - + + + +
{/* Process grid */}

- Active Processes + {t.control.activeProcesses}

{processes.length === 0 ? ( -

No processes found

+

{t.control.noProcesses}

) : (
{processes.map((p) => ( diff --git a/graph-ui/src/components/GraphScene.test.ts b/graph-ui/src/components/GraphScene.test.ts new file mode 100644 index 000000000..75fcb2154 --- /dev/null +++ b/graph-ui/src/components/GraphScene.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { GRAPH_CANVAS_DPR } from "./GraphScene"; + +describe("GraphScene render limits", () => { + it("caps the high-DPI WebGL backing store below the MSAA failure range", () => { + expect(GRAPH_CANVAS_DPR[0]).toBe(1); + expect(GRAPH_CANVAS_DPR[1]).toBeLessThanOrEqual(1.5); + }); +}); diff --git a/graph-ui/src/components/GraphScene.tsx b/graph-ui/src/components/GraphScene.tsx index fa150be36..6aeceb6b2 100644 --- a/graph-ui/src/components/GraphScene.tsx +++ b/graph-ui/src/components/GraphScene.tsx @@ -45,6 +45,8 @@ function CameraAnimator({ target }: { target: CameraTarget | null }) { /* ── Idle auto-rotation ──────────────────────────────────── */ const IDLE_TIMEOUT_MS = 60_000; +export const GRAPH_CANVAS_DPR: [number, number] = [1, 1.5]; +export const GRAPH_COMPOSER_MULTISAMPLING = 0; function IdleAutoRotate({ controlsRef, @@ -108,8 +110,12 @@ export function GraphScene({ @@ -176,7 +182,7 @@ export function GraphScene({ - + { + it("reports when the graph response is truncated for render safety", () => { + const data = { + nodes: Array.from({ length: 2000 }, (_, id) => ({ + id, + x: 0, + y: 0, + z: 0, + label: "Function", + name: `fn${id}`, + size: 1, + color: "#ffffff", + })), + edges: [], + total_nodes: 43729, + } satisfies GraphData; + + expect(formatGraphLimitNotice(data)).toBe( + "Showing 2,000 of 43,729 nodes. Use filters to narrow.", + ); + }); + + it("stays quiet when the full graph is rendered", () => { + const data = { + nodes: [], + edges: [], + total_nodes: 0, + } satisfies GraphData; + + expect(formatGraphLimitNotice(data)).toBeNull(); + }); +}); diff --git a/graph-ui/src/components/GraphTab.tsx b/graph-ui/src/components/GraphTab.tsx index ea9b7bb5b..0e6c8fb08 100644 --- a/graph-ui/src/components/GraphTab.tsx +++ b/graph-ui/src/components/GraphTab.tsx @@ -29,6 +29,11 @@ interface GraphTabProps { project: string | null; } +export function formatGraphLimitNotice(data: GraphData | null): string | null { + if (!data || data.total_nodes <= data.nodes.length) return null; + return `Showing ${data.nodes.length.toLocaleString()} of ${data.total_nodes.toLocaleString()} nodes. Use filters to narrow.`; +} + export function GraphTab({ project }: GraphTabProps) { const { data, loading, error, fetchOverview } = useGraphData(); const [highlightedIds, setHighlightedIds] = useState | null>(null); @@ -38,6 +43,7 @@ export function GraphTab({ project }: GraphTabProps) { const [showLabels, setShowLabels] = useState(true); const [leftWidth, setLeftWidth] = useState(() => loadWidth("cbm-left-w", 260)); const [rightWidth, setRightWidth] = useState(() => loadWidth("cbm-right-w", 280)); + const limitNotice = formatGraphLimitNotice(data); /* Filter state — all enabled by default */ const [enabledLabels, setEnabledLabels] = useState>(new Set()); @@ -282,6 +288,9 @@ export function GraphTab({ project }: GraphTabProps) { filtered from {data.nodes.length.toLocaleString()}

)} + {limitNotice && ( +

{limitNotice}

+ )} {highlightedIds && highlightedIds.size > 0 && (

{highlightedIds.size} selected diff --git a/graph-ui/src/components/Sidebar.tsx b/graph-ui/src/components/Sidebar.tsx index 161dffcb3..73c3f898f 100644 --- a/graph-ui/src/components/Sidebar.tsx +++ b/graph-ui/src/components/Sidebar.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from "react"; import { ScrollArea } from "@/components/ui/scroll-area"; import type { GraphNode } from "../lib/types"; +import { useUiMessages } from "../lib/i18n"; interface SidebarProps { nodes: GraphNode[]; @@ -105,6 +106,7 @@ function TreeItem({ dir, depth, onSelect, selectedPath }: { } export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) { + const t = useUiMessages(); const [search, setSearch] = useState(""); const tree = useMemo(() => flattenSingleChild(buildFileTree(nodes)), [nodes]); @@ -122,7 +124,7 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {

setSearch(e.target.value)} className="w-full bg-white/[0.04] border border-white/[0.06] rounded-lg px-3 py-1.5 text-[12px] text-foreground placeholder-foreground/25 outline-none focus:border-primary/40 focus:bg-white/[0.06] transition-all" @@ -134,7 +136,9 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
{filtered ? ( filtered.length === 0 ? ( -

No matches

+

+ {t.common.noMatches} +

) : ( filtered.map((n) => (
)} diff --git a/graph-ui/src/components/StatsTab.test.tsx b/graph-ui/src/components/StatsTab.test.tsx new file mode 100644 index 000000000..1a3df5b6f --- /dev/null +++ b/graph-ui/src/components/StatsTab.test.tsx @@ -0,0 +1,95 @@ +/* @vitest-environment jsdom */ +import "@testing-library/jest-dom/vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { StatsTab } from "./StatsTab"; + +function mockProjectsFetch(extra?: (url: string, init?: RequestInit) => Response | undefined) { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const overridden = extra?.(url, init); + if (overridden) return overridden; + if (url === "/rpc") { + return new Response(JSON.stringify({ + result: { content: [{ text: JSON.stringify({ projects: [] }) }] }, + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url.startsWith("/api/ui-config")) { + return new Response(JSON.stringify({ lang: "en" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url.startsWith("/api/browse")) { + return new Response(JSON.stringify({ + path: "/home/dev", + parent: "/home", + dirs: ["alpha", "beta"], + roots: ["/", "D:/"], + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url === "/api/index") { + return new Response(JSON.stringify({ status: "indexing", slot: 0 }), { + status: 202, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("{}", { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +describe("StatsTab index modal", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("submits a custom path and project name", async () => { + let submitted: unknown = null; + mockProjectsFetch((url, init) => { + if (url === "/api/index") { + submitted = JSON.parse(String(init?.body)); + return new Response(JSON.stringify({ status: "indexing", slot: 0 }), { + status: 202, + headers: { "Content-Type": "application/json" }, + }); + } + return undefined; + }); + + render( {}} />); + fireEvent.click(await screen.findByRole("button", { name: "Index your first repository" })); + + fireEvent.change(await screen.findByLabelText("Repository path"), { + target: { value: "D:\\work\\信租风控通后端" }, + }); + fireEvent.change(screen.getByLabelText("Project name"), { + target: { value: "信租风控通后端" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Index This Folder" })); + + await waitFor(() => { + expect(submitted).toEqual({ + root_path: "D:\\work\\信租风控通后端", + project_name: "信租风控通后端", + }); + }); + }); + + it("filters picker rows and exposes quick row indexing", async () => { + mockProjectsFetch(); + + render( {}} />); + fireEvent.click(await screen.findByRole("button", { name: "Index your first repository" })); + + fireEvent.change(await screen.findByPlaceholderText("Filter folders"), { + target: { value: "bet" }, + }); + + expect(screen.queryByText("alpha")).not.toBeInTheDocument(); + expect(screen.getByText("beta")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Index beta" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Browse D:/" })).toBeInTheDocument(); + }); +}); diff --git a/graph-ui/src/components/StatsTab.tsx b/graph-ui/src/components/StatsTab.tsx index 988902714..3c439dce4 100644 --- a/graph-ui/src/components/StatsTab.tsx +++ b/graph-ui/src/components/StatsTab.tsx @@ -1,7 +1,8 @@ -import { useMemo, useState, useCallback, useEffect } from "react"; +import { useMemo, useState, useCallback, useEffect, useRef } from "react"; import { ScrollArea } from "@/components/ui/scroll-area"; import { useProjects } from "../hooks/useProjects"; import { colorForLabel } from "../lib/colors"; +import { useUiMessages } from "../lib/i18n"; interface StatsTabProps { onSelectProject: (project: string) => void; @@ -10,6 +11,7 @@ interface StatsTabProps { /* ── Glowy health dot ───────────────────────────────────── */ function HealthDot({ name }: { name: string }) { + const t = useUiMessages(); const [status, setStatus] = useState<"loading" | "healthy" | "corrupt" | "missing">("loading"); const [info, setInfo] = useState(""); @@ -34,9 +36,9 @@ function HealthDot({ name }: { name: string }) { status === "corrupt" ? "#f87171" : "#555"; const label = - status === "healthy" ? "Database healthy" : - status === "missing" ? "Database missing" : - status === "corrupt" ? "Database unhealthy" : "Checking..."; + status === "healthy" ? t.projects.healthHealthy : + status === "missing" ? t.projects.healthMissing : + status === "corrupt" ? t.projects.healthCorrupt : t.projects.healthChecking; return (
@@ -64,6 +66,7 @@ function HealthDot({ name }: { name: string }) { /* ── ADR button + modal ─────────────────────────────────── */ function AdrButton({ project }: { project: string }) { + const t = useUiMessages(); const [hasAdr, setHasAdr] = useState(null); const [open, setOpen] = useState(false); const [content, setContent] = useState(""); @@ -117,13 +120,13 @@ function AdrButton({ project }: { project: string }) {
e.stopPropagation()}>
-

Architecture Decision Record

+

{t.adr.title}

{project}

{updatedAt && ( -

Last updated: {updatedAt}

+

{t.adr.lastUpdated}: {updatedAt}

)}