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
27 changes: 15 additions & 12 deletions graph-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<TabId>("stats");
const [selectedProject, setSelectedProject] = useState<string | null>(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 (
<div className="h-screen flex flex-col bg-background text-foreground">
Expand All @@ -28,25 +29,27 @@ export function App() {

{/* Tabs inline in header */}
<nav className="flex items-center gap-0.5">
{TABS.map((t) => (
{tabs.map((tab) => (
<button
key={t.id}
onClick={() => setActiveTab(t.id)}
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-3 py-1 rounded-md text-[12px] font-medium transition-all ${
activeTab === t.id
activeTab === tab.id
? "bg-primary/15 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-white/[0.04]"
}`}
>
{t.label}
{tab.label}
</button>
))}
</nav>
</div>

{selectedProject && (
<div className="flex items-center gap-2 px-3 py-1 rounded-lg bg-white/[0.04] border border-border/30">
<span className="text-[10px] text-foreground/30 uppercase tracking-wider">Graph</span>
<span className="text-[10px] text-foreground/30 uppercase tracking-wider">
{t.graph.selectedLabel}
</span>
<span className="text-[11px] text-primary font-mono truncate max-w-[300px]">
{selectedProject}
</span>
Expand Down
34 changes: 19 additions & 15 deletions graph-ui/src/components/ControlTab.tsx
Original file line number Diff line number Diff line change
@@ -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 ────────────────────────────────────── */

Expand Down Expand Up @@ -30,6 +31,7 @@ function ProcessCard({ proc, selected, onSelect, onKill }: {
proc: ProcessInfo; selected: boolean;
onSelect: () => void; onKill: () => void;
}) {
const t = useUiMessages();
return (
<button
onClick={onSelect}
Expand All @@ -46,15 +48,15 @@ function ProcessCard({ proc, selected, onSelect, onKill }: {
PID {proc.pid}
</span>
{proc.is_self && (
<span className="text-[9px] px-1.5 py-0.5 rounded bg-primary/15 text-primary font-medium">THIS</span>
<span className="text-[9px] px-1.5 py-0.5 rounded bg-primary/15 text-primary font-medium">{t.control.thisProcess}</span>
)}
</div>
{!proc.is_self && (
<button
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}
</button>
)}
</div>
Expand All @@ -69,7 +71,7 @@ function ProcessCard({ proc, selected, onSelect, onKill }: {
<p className="text-[13px] font-semibold tabular-nums text-foreground/70">{proc.rss_mb.toFixed(0)} MB</p>
</div>
<div>
<p className="text-[9px] text-foreground/20 uppercase">Uptime</p>
<p className="text-[9px] text-foreground/20 uppercase">{t.control.uptime}</p>
<p className="text-[13px] font-semibold tabular-nums text-foreground/70">{proc.elapsed}</p>
</div>
</div>
Expand All @@ -82,6 +84,7 @@ function ProcessCard({ proc, selected, onSelect, onKill }: {
/* ── Log viewer ─────────────────────────────────────────── */

function LogViewer() {
const t = useUiMessages();
const [lines, setLines] = useState<string[]>([]);

useEffect(() => {
Expand All @@ -100,13 +103,13 @@ function LogViewer() {
return (
<div className="rounded-xl border border-border/30 bg-black/30 overflow-hidden">
<div className="px-4 py-2 border-b border-border/20">
<span className="text-[11px] font-medium text-foreground/40">Process Logs</span>
<span className="text-[11px] font-medium text-foreground/40">{t.control.processLogs}</span>
<span className="text-[10px] text-foreground/15 ml-2">{lines.length} lines</span>
</div>
<ScrollArea className="h-[400px]">
<div className="p-3 font-mono text-[10px] leading-relaxed">
{lines.length === 0 ? (
<p className="text-foreground/15 text-center py-8">No logs yet</p>
<p className="text-foreground/15 text-center py-8">{t.control.noLogs}</p>
) : (
lines.map((line, i) => {
const isErr = line.includes("level=error");
Expand All @@ -132,6 +135,7 @@ function LogViewer() {
/* ── Main Control Tab ───────────────────────────────────── */

export function ControlTab() {
const t = useUiMessages();
const [processes, setProcesses] = useState<ProcessInfo[]>([]);
const [selfMetrics, setSelfMetrics] = useState({ rss_mb: 0, user_cpu: 0, sys_cpu: 0 });
const [selectedPid, setSelectedPid] = useState<number | null>(null);
Expand All @@ -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",
Expand All @@ -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);
Expand All @@ -174,32 +178,32 @@ export function ControlTab() {
return (
<ScrollArea className="h-full">
<div className="p-8 max-w-4xl mx-auto">
<h2 className="text-[15px] font-semibold text-foreground/80 mb-6">Control Panel</h2>
<h2 className="text-[15px] font-semibold text-foreground/80 mb-6">{t.control.panel}</h2>

{/* Aggregate gauges */}
<div className="flex gap-4 mb-8">
<Gauge label="Total CPU" value={totalCpu} max={100 * processes.length || 100} unit="%" color="text-foreground/80" />
<Gauge label="Total RAM" value={totalRam} max={4096} unit="MB" color="text-foreground/80" />
<Gauge label="Processes" value={processes.length} max={10} unit="" color="text-primary" />
<Gauge label="Self RAM" value={selfMetrics.rss_mb} max={2048} unit="MB" color="text-primary" />
<Gauge label={t.control.totalCpu} value={totalCpu} max={100 * processes.length || 100} unit="%" color="text-foreground/80" />
<Gauge label={t.control.totalRam} value={totalRam} max={4096} unit="MB" color="text-foreground/80" />
<Gauge label={t.control.processes} value={processes.length} max={10} unit="" color="text-primary" />
<Gauge label={t.control.selfRam} value={selfMetrics.rss_mb} max={2048} unit="MB" color="text-primary" />
</div>

{/* Process grid */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h3 className="text-[13px] font-medium text-foreground/50">
Active Processes
{t.control.activeProcesses}
</h3>
<button
onClick={fetchProcesses}
className="text-[11px] text-primary/60 hover:text-primary transition-colors"
>
Refresh
{t.common.refresh}
</button>
</div>

{processes.length === 0 ? (
<p className="text-foreground/20 text-[12px] text-center py-8">No processes found</p>
<p className="text-foreground/20 text-[12px] text-center py-8">{t.control.noProcesses}</p>
) : (
<div className="grid grid-cols-2 gap-3">
{processes.map((p) => (
Expand Down
9 changes: 9 additions & 0 deletions graph-ui/src/components/GraphScene.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
12 changes: 9 additions & 3 deletions graph-ui/src/components/GraphScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -108,8 +110,12 @@ export function GraphScene({
<Canvas
camera={{ position: [0, 0, 800], fov: 50, near: 0.1, far: 100000 }}
style={{ background: "#06090f" }}
dpr={[1, 2]}
gl={{ antialias: true, alpha: false }}
dpr={GRAPH_CANVAS_DPR}
gl={{
antialias: false,
alpha: false,
powerPreference: "high-performance",
}}
>
<color attach="background" args={["#06090f"]} />
<ambientLight intensity={0.5} />
Expand Down Expand Up @@ -176,7 +182,7 @@ export function GraphScene({
<CameraAnimator target={cameraTarget} />
<IdleAutoRotate controlsRef={controlsRef} />

<EffectComposer>
<EffectComposer multisampling={GRAPH_COMPOSER_MULTISAMPLING}>
<Bloom
luminanceThreshold={0.3}
luminanceSmoothing={0.7}
Expand Down
36 changes: 36 additions & 0 deletions graph-ui/src/components/GraphTab.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { formatGraphLimitNotice } from "./GraphTab";
import type { GraphData } from "../lib/types";

describe("formatGraphLimitNotice", () => {
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();
});
});
9 changes: 9 additions & 0 deletions graph-ui/src/components/GraphTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Set<number> | null>(null);
Expand All @@ -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<Set<string>>(new Set());
Expand Down Expand Up @@ -282,6 +288,9 @@ export function GraphTab({ project }: GraphTabProps) {
filtered from {data.nodes.length.toLocaleString()}
</p>
)}
{limitNotice && (
<p className="text-amber-300/80 mt-0.5">{limitNotice}</p>
)}
{highlightedIds && highlightedIds.size > 0 && (
<p className="text-cyan-400/50 mt-0.5">
{highlightedIds.size} selected
Expand Down
10 changes: 7 additions & 3 deletions graph-ui/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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[];
Expand Down Expand Up @@ -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]);

Expand All @@ -122,7 +124,7 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
<div className="relative">
<input
type="text"
placeholder="Search..."
placeholder={t.graph.search}
value={search}
onChange={(e) => 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"
Expand All @@ -134,7 +136,9 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
<div className="py-1">
{filtered ? (
filtered.length === 0 ? (
<p className="text-foreground/20 text-[12px] px-4 py-6 text-center">No matches</p>
<p className="text-foreground/20 text-[12px] px-4 py-6 text-center">
{t.common.noMatches}
</p>
) : (
filtered.map((n) => (
<button
Expand All @@ -160,7 +164,7 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
onClick={() => onSelectPath("", new Set())}
className="w-full px-3 py-1.5 rounded-lg bg-white/[0.04] hover:bg-white/[0.07] text-[11px] text-foreground/40 font-medium transition-all"
>
Clear selection
{t.graph.clearSelection}
</button>
</div>
)}
Expand Down
Loading
Loading