From bc1debc7e14629b77337b28cc7fc628043f6644e Mon Sep 17 00:00:00 2001 From: Danil-Didkovskiy Date: Wed, 17 Jun 2026 20:52:22 +0300 Subject: [PATCH 1/8] Add member history tracking and enhance process detail view --- .../components/metrics/area-layer.tsx | 26 +- src/renderer/components/metrics/cpu-graph.tsx | 43 +- .../components/metrics/memory-graph.tsx | 37 +- .../components/processes/process-detail.tsx | 515 ++++++++++++------ .../processes/process-explorer-view.tsx | 11 +- .../components/processes/process-icon.tsx | 9 +- .../components/processes/process-row.tsx | 3 +- .../processes/use-process-actions.ts | 4 +- .../processes/use-process-histories.ts | 132 ++++- src/renderer/domain/process-detail.ts | 38 +- src/renderer/domain/process-list.ts | 175 ++++-- src/renderer/domain/sample-history.ts | 15 + src/renderer/index.css | 35 ++ tests/unit/process-detail.test.ts | 58 +- tests/unit/process-list.test.ts | 165 ++++-- tests/unit/sample-history.test.ts | 21 +- 16 files changed, 973 insertions(+), 314 deletions(-) diff --git a/src/renderer/components/metrics/area-layer.tsx b/src/renderer/components/metrics/area-layer.tsx index dfbd555..79f0144 100644 --- a/src/renderer/components/metrics/area-layer.tsx +++ b/src/renderer/components/metrics/area-layer.tsx @@ -1,12 +1,6 @@ import type { AreaRun } from "@/domain/area-path"; import { HISTORY_CAPACITY } from "@/domain/sample-history"; -/** - * Shared SVG layers for the time-series graphs (CPU, network). The graphs draw - * in a `0..capacity × 0..100` viewBox stretched to the row - * (`preserveAspectRatio="none"`), so strokes opt out of scaling. - */ - /** * Axis baseline split at the first filled history slot: dashed over the * still-unobserved left region (so a warming-up graph reads as "recording @@ -26,7 +20,6 @@ export function Baseline({ y, offset }: { y: number; offset: number }) { ); } -/** Area runs as a translucent fill under a brighter edge line, in currentColor. */ export function AreaLayer({ runs, className }: { runs: AreaRun[]; className?: string }) { return ( @@ -48,7 +41,20 @@ export function AreaLayer({ runs, className }: { runs: AreaRun[]; className?: st ); } -/** Full-height highlight band over the scrubbed history slot. */ -export function ScrubBand({ x }: { x: number }) { - return ; +/** + * Full-height highlight band over the scrubbed history slot. A `pinned` band + * (a tick the user clicked to hold) reads stronger than a transient hover band. + */ +export function ScrubBand({ x, pinned = false }: { x: number; pinned?: boolean }) { + return ( + + ); } diff --git a/src/renderer/components/metrics/cpu-graph.tsx b/src/renderer/components/metrics/cpu-graph.tsx index 5b64f80..fc72e9d 100644 --- a/src/renderer/components/metrics/cpu-graph.tsx +++ b/src/renderer/components/metrics/cpu-graph.tsx @@ -1,12 +1,16 @@ -import { useRef, type PointerEvent as ReactPointerEvent } from "react"; +import { useRef } from "react"; import { cn } from "@/lib/utils"; import type { MetricState } from "@/domain/metric-view"; import { areaRuns } from "@/domain/area-path"; -import { HISTORY_CAPACITY, sampleIndexAtFraction, type HistorySample } from "@/domain/sample-history"; +import { + HISTORY_CAPACITY, + pickedIndexAtFraction, + sampleIndexAtFraction, + type HistorySample, +} from "@/domain/sample-history"; import { AreaLayer, Baseline, ScrubBand } from "@/components/metrics/area-layer"; -/** A CPU percent reading, or `null` for a tick whose reading was not OK. */ export type CpuSample = HistorySample; const FILL_BY_STATE: Record = { @@ -22,21 +26,22 @@ const MIN_AMPLITUDE = 1.5; // floor so a ~0% sample still draws a visible line const AXIS_FLOOR = 20; // smallest y-axis max, so a flat-idle graph is not "maxed" const BASELINE_Y = 99.5; // bottom axis, inset so its non-scaling stroke is not clipped by the viewBox edge -/** - * Area graph of recent CPU usage, in the same style as the network chart: - * a translucent fill under an edge line, rising from the bottom. Color - * follows the metric state rather than a category. - */ +/** Area chart of recent CPU usage, in the same style as the network chart. */ export function CpuGraph({ history, scrubIndex, + pinned = false, state, onScrub, + onPick, }: { history: CpuSample[]; scrubIndex: number | null; + pinned?: boolean; state: MetricState; onScrub: (index: number | null) => void; + /** Clicking a tick picks it (held until cleared); omit to disable picking. */ + onPick?: (index: number | null) => void; }) { const ref = useRef(null); const offset = HISTORY_CAPACITY - history.length; @@ -45,10 +50,19 @@ export function CpuGraph({ const axisMax = Math.max(AXIS_FLOOR, ...history.map((sample) => sample ?? 0)); const runs = areaRuns(history, offset, (sample) => Math.max(MIN_AMPLITUDE, (sample / axisMax) * PEAK), 100, -1); - const handleMove = (event: ReactPointerEvent) => { + const fractionAt = (event: { clientX: number }): number | null => { const rect = ref.current?.getBoundingClientRect(); - if (!rect || rect.width === 0) return; - onScrub(sampleIndexAtFraction((event.clientX - rect.left) / rect.width, history.length)); + if (!rect || rect.width === 0) return null; + return (event.clientX - rect.left) / rect.width; + }; + const hoverIndex = (event: { clientX: number }): number | null => { + const fraction = fractionAt(event); + return fraction === null ? null : sampleIndexAtFraction(fraction, history.length); + }; + // Off-data click returns null (resume live); hover clamps to oldest sample. + const pickIndex = (event: { clientX: number }): number | null => { + const fraction = fractionAt(event); + return fraction === null ? null : pickedIndexAtFraction(fraction, history.length); }; return ( @@ -56,15 +70,16 @@ export function CpuGraph({ ref={ref} viewBox={`0 0 ${HISTORY_CAPACITY} 100`} preserveAspectRatio="none" - className={cn("h-full w-full", fill)} + className={cn("h-full w-full", fill, onPick && "cursor-pointer")} role="img" aria-label="Recent CPU usage" - onPointerMove={handleMove} + onPointerMove={(event) => onScrub(hoverIndex(event))} onPointerLeave={() => onScrub(null)} + onClick={onPick ? (event) => onPick(pickIndex(event)) : undefined} > - {scrubIndex !== null ? : null} + {scrubIndex !== null ? : null} ); } diff --git a/src/renderer/components/metrics/memory-graph.tsx b/src/renderer/components/metrics/memory-graph.tsx index 93dbb0d..7c9f05e 100644 --- a/src/renderer/components/metrics/memory-graph.tsx +++ b/src/renderer/components/metrics/memory-graph.tsx @@ -1,7 +1,13 @@ -import { useRef, type PointerEvent as ReactPointerEvent } from "react"; +import { useRef } from "react"; +import { cn } from "@/lib/utils"; import { areaRuns } from "@/domain/area-path"; -import { HISTORY_CAPACITY, sampleIndexAtFraction, type HistorySample } from "@/domain/sample-history"; +import { + HISTORY_CAPACITY, + pickedIndexAtFraction, + sampleIndexAtFraction, + type HistorySample, +} from "@/domain/sample-history"; import { AreaLayer, Baseline, ScrubBand } from "@/components/metrics/area-layer"; const PEAK = 88; @@ -13,11 +19,16 @@ const MIN_SPAN_BYTES = 16 * 1024 * 1024; export function MemoryGraph({ history, scrubIndex, + pinned = false, onScrub, + onPick, }: { history: HistorySample[]; scrubIndex: number | null; + pinned?: boolean; onScrub: (index: number | null) => void; + /** Clicking a tick picks it (held until cleared); omit to disable picking. */ + onPick?: (index: number | null) => void; }) { const ref = useRef(null); const offset = HISTORY_CAPACITY - history.length; @@ -29,10 +40,19 @@ export function MemoryGraph({ const span = Math.max(MIN_SPAN_BYTES, max - base); const runs = areaRuns(history, offset, (sample) => ((sample - base) / span) * PEAK, 100, -1); - const handleMove = (event: ReactPointerEvent) => { + const fractionAt = (event: { clientX: number }): number | null => { const rect = ref.current?.getBoundingClientRect(); - if (!rect || rect.width === 0) return; - onScrub(sampleIndexAtFraction((event.clientX - rect.left) / rect.width, history.length)); + if (!rect || rect.width === 0) return null; + return (event.clientX - rect.left) / rect.width; + }; + const hoverIndex = (event: { clientX: number }): number | null => { + const fraction = fractionAt(event); + return fraction === null ? null : sampleIndexAtFraction(fraction, history.length); + }; + // Off-data click returns null (resume live); hover clamps to oldest sample. + const pickIndex = (event: { clientX: number }): number | null => { + const fraction = fractionAt(event); + return fraction === null ? null : pickedIndexAtFraction(fraction, history.length); }; return ( @@ -40,15 +60,16 @@ export function MemoryGraph({ ref={ref} viewBox={`0 0 ${HISTORY_CAPACITY} 100`} preserveAspectRatio="none" - className="h-full w-full text-mem-app" + className={cn("h-full w-full text-mem-app", onPick && "cursor-pointer")} role="img" aria-label="Recent memory footprint" - onPointerMove={handleMove} + onPointerMove={(event) => onScrub(hoverIndex(event))} onPointerLeave={() => onScrub(null)} + onClick={onPick ? (event) => onPick(pickIndex(event)) : undefined} > - {scrubIndex !== null ? : null} + {scrubIndex !== null ? : null} ); } diff --git a/src/renderer/components/processes/process-detail.tsx b/src/renderer/components/processes/process-detail.tsx index a66260d..4c4ecff 100644 --- a/src/renderer/components/processes/process-detail.tsx +++ b/src/renderer/components/processes/process-detail.tsx @@ -1,22 +1,36 @@ import { ChevronLeft, ChevronRight, Clock, Cpu, MemoryStick, User } from "lucide-react"; -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type AnimationEvent as ReactAnimationEvent, + type CSSProperties, + type ReactNode, +} from "react"; import { cn } from "@/lib/utils"; import { UNAVAILABLE_TEXT, formatBytes, formatCpuPercentPrecise, formatStartTime } from "@/lib/format"; import type { ActionState, ProcessActionKind } from "@/gen/process_explorer"; -import { CopyButton, DisclosureContent } from "@/components/processes/disclosure"; +import { CopyButton } from "@/components/processes/disclosure"; import { MemberRow } from "@/components/processes/member-row"; import { ProcessActions } from "@/components/processes/process-actions"; import { ScrollFade } from "@/components/processes/scroll-fade"; import { ProcessIcon } from "@/components/processes/process-icon"; import { ProcessSortControl } from "@/components/processes/process-sort-control"; -import { MetricRowHeader, MeterTooltip, ValueUnit } from "@/components/metrics/metric-row-header"; +import { MetricRowHeader, ValueUnit } from "@/components/metrics/metric-row-header"; import { CpuGraph } from "@/components/metrics/cpu-graph"; import { MemoryGraph } from "@/components/metrics/memory-graph"; import { useOrderPin } from "@/components/processes/use-order-pin"; -import { HISTORY_CAPACITY, type HistorySample } from "@/domain/sample-history"; -import { metricValueText, type SortMode } from "@/domain/process-list"; -import { memberKey } from "@/domain/process-detail"; +import { type HistorySample } from "@/domain/sample-history"; +import { + metricValueText, + type IconTable, + type MemberMetricSample, + type SortMode, +} from "@/domain/process-list"; +import { memberKey, rankMemberSamples } from "@/domain/process-detail"; import type { DetailField, DetailMember, @@ -30,6 +44,12 @@ const TOTAL_LABEL: Record = { memory: "RAM", }; +function shouldAnimatePanelClose(): boolean { + return typeof window !== "undefined" && + window.matchMedia !== undefined && + !window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + /** * The process detail view for one selected group (or one drilled-in process): * identity, started-at, executable path (with copy), command line, parent PID, @@ -44,6 +64,8 @@ const TOTAL_LABEL: Record = { export function ProcessDetailView({ detail, history, + memberHistory, + icons, sort, actions, actionsBusy, @@ -52,9 +74,12 @@ export function ProcessDetailView({ onBack, onOpenMember, onRunAction, + onInspectingChange, }: { detail: ProcessDetail history: HistorySample[] + memberHistory: MemberMetricSample[][] + icons: IconTable sort: SortMode actions: ActionState[] actionsBusy: boolean @@ -63,18 +88,78 @@ export function ProcessDetailView({ onBack: () => void onOpenMember: (pid: number, startedAtUnixMs?: number) => void onRunAction: (kind: ProcessActionKind) => void + onInspectingChange?: (inspecting: boolean) => void }) { const secondary = detail.bundleIdentifier ?? detail.executableName; - // The synthetic System group has no single identity: its subtitle is the - // member count, and the per-process fields/actions below are omitted. - const metadata = detail.system - ? `${detail.memberCount} ${detail.memberCount === 1 ? "process" : "processes"}` - : `${secondary ? `${secondary} - ` : ""}PID ${detail.pid}${ - detail.parentPid !== undefined ? ` - Parent ${detail.parentPid}` : "" - }`; + const metadata = `${secondary ? `${secondary} - ` : ""}PID ${detail.pid}${ + detail.parentPid !== undefined ? ` - Parent ${detail.parentPid}` : "" + }`; const metadataTitle = detail.notResponding ? `Not Responding - ${metadata}` : metadata; const grouped = detail.memberCount > 1; + // scrubIndex = transient hover (moves only the readout/band); pinned = a + // clicked tick that freezes the graph and drives the breakdown. Hover never freezes. + const [scrubIndex, setScrubIndex] = useState(null); + const [pinned, setPinned] = useState(null); + const bandIndex = pinned ?? scrubIndex; + const inspecting = pinned !== null; + + const pickIndex = useCallback((index: number | null) => { + setPinned((current) => (index === null || current === index ? null : index)); + }, []); + + // Freeze history while inspecting so the held tick doesn't scroll off. + useEffect(() => { + onInspectingChange?.(inspecting); + }, [inspecting, onInspectingChange]); + useEffect(() => () => onInspectingChange?.(false), [onInspectingChange]); + + // Members disclosure. Closed: graph sits at the bottom, stats visible. Open: the + // graph+members lift into a top overlay covering the stats (see GraphAndMembers). + const [membersOpen, setMembersOpen] = useState(false); + const [closing, setClosing] = useState(false); + const [slideFrom, setSlideFrom] = useState(0); + const contentRef = useRef(null); + const inflowGraphRef = useRef(null); + + const measureSlide = useCallback((): number => { + const content = contentRef.current?.getBoundingClientRect(); + const graph = inflowGraphRef.current?.getBoundingClientRect(); + if (!content || !graph) { + return 0; + } + return Math.max(0, graph.top - content.top); + }, []); + + const toggleMembers = useCallback(() => { + setMembersOpen((open) => { + setSlideFrom(measureSlide()); + setClosing(open && shouldAnimatePanelClose()); + if (open) { + setPinned(null); + setScrubIndex(null); + } + return !open; + }); + }, [measureSlide]); + const onOverlayAnimationEnd = useCallback( + (event: ReactAnimationEvent) => { + if (event.target === event.currentTarget && closing) { + setClosing(false); + } + }, + [closing], + ); + const canPin = grouped || pinned !== null; + const overlayMounted = (membersOpen || closing) && canPin; + + useEffect(() => { + if (!grouped && pinned === null) { + setMembersOpen(false); + setClosing(false); + } + }, [grouped, pinned]); + const headingRef = useRef(null); useEffect(() => { headingRef.current?.focus(); @@ -82,7 +167,7 @@ export function ProcessDetailView({ return (
-
+
-
-
- -
- -

- {detail.name} -

-
- -

- {detail.notResponding ? ( - Not Responding - - ) : null} - {metadata} -

-
-
-
+
+
+
+ +
+ +

+ {detail.name} +

+
+ +

+ {detail.notResponding ? ( + Not Responding - + ) : null} + {metadata} +

+
+
+
- + - {detail.system ? null : ( -
+
- )} - +
+ +
+
- {grouped ? ( - + {overlayMounted ? ( +
+ +
) : null}
- {detail.system ? null : ( - - )} +
); } -/** Recent trend for the selected process or group under the active metric. */ -function ProcessMetricGraph({ detail, history }: { detail: ProcessDetail; history: HistorySample[] }) { +/** + * The graph + members card: the trend graph and, for a grouped process, a + * "Members (N)" disclosure. Closed, it shows just the graph and sits in the + * detail's normal flow. Open, the members list appears and the owner lifts the + * whole card into a top overlay. + */ +function GraphAndMembers({ + detail, + history, + memberHistory, + icons, + sort, + scrubIndex, + pinnedIndex, + membersOpen, + canPin, + onScrub, + onPick, + onToggleMembers, + onOpenMember, +}: { + detail: ProcessDetail + history: HistorySample[] + memberHistory: MemberMetricSample[][] + icons: IconTable + sort: SortMode + scrubIndex: number | null + pinnedIndex: number | null + membersOpen: boolean + canPin: boolean + onScrub: (index: number | null) => void + onPick: (index: number | null) => void + onToggleMembers: () => void + onOpenMember: (pid: number, startedAtUnixMs?: number) => void +}) { + const showMembers = membersOpen && canPin; + const pinnedMembers = pinnedIndex !== null ? memberHistory[pinnedIndex] : undefined; + const memberCount = pinnedMembers !== undefined ? pinnedMembers.length : detail.memberCount; + return ( +
+ + + {canPin ? ( + + ) : null} + + {showMembers ? ( + + ) : null} +
+ ); +} + +/** + * Recent trend for the selected process or group under the active metric. The + * scrub index is controlled by the parent: `onScrub` reports the hover (graph + * read-out only), while `onPick` (wired only when the members list is open) + * holds a tick that drives the breakdown. The header value reflects the + * inspected tick, else the live total. + */ +function ProcessMetricGraph({ + detail, + history, + scrubIndex, + pinned, + divider = true, + onScrub, + onPick, +}: { + detail: ProcessDetail + history: HistorySample[] + scrubIndex: number | null + pinned: boolean + divider?: boolean + onScrub: (index: number | null) => void + onPick?: (index: number | null) => void +}) { const isCpu = detail.totalSort === "cpu"; - const [scrubIndex, setScrubIndex] = useState(null); const format = isCpu ? formatCpuPercentPrecise : (value: number) => formatBytes(value, true); @@ -194,37 +420,36 @@ function ProcessMetricGraph({ detail, history }: { detail: ProcessDetail; histor ? format(detail.totalValue) : metricValueText(detail.total.state, detail.total.text); - const scrubPercent = - scrubIndex !== null - ? ((HISTORY_CAPACITY - history.length + scrubIndex + 0.5) / HISTORY_CAPACITY) * 100 - : null; - return ( -
+
-
+
{isCpu ? ( - + ) : ( - + )} - {scrubbed !== null && scrubPercent !== null ? ( - - {format(scrubbed)} - - ) : null}
); } -/** - * The secondary-stat strip under the header (user, threads, CPU time). The - * System group hides the user stat - its members run as many different users, - * so the representative's would be misleading. - */ +/** The secondary-stat strip under the header: user, threads, CPU time. */ function HeaderStats({ detail, grouped }: { detail: ProcessDetail; grouped: boolean }) { const threadsText = detail.threadCount.state === "ok" @@ -233,18 +458,14 @@ function HeaderStats({ detail, grouped }: { detail: ProcessDetail; grouped: bool return (
- {detail.system ? ( -