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..f5802e5 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 { scalarGraphScale } from "@/domain/graph-scale"; +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,33 +26,44 @@ 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; const fill = FILL_BY_STATE[state]; + const { offset, runs } = scalarGraphScale(history, { + axisFloor: AXIS_FLOOR, + peak: PEAK, + minAmplitude: MIN_AMPLITUDE, + }); - 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 +71,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..9621cde 100644 --- a/src/renderer/components/metrics/memory-graph.tsx +++ b/src/renderer/components/metrics/memory-graph.tsx @@ -1,38 +1,54 @@ -import { useRef, type PointerEvent as ReactPointerEvent } from "react"; +import { useRef } from "react"; -import { areaRuns } from "@/domain/area-path"; -import { HISTORY_CAPACITY, sampleIndexAtFraction, type HistorySample } from "@/domain/sample-history"; +import { cn } from "@/lib/utils"; +import { scalarGraphScale } from "@/domain/graph-scale"; +import { + HISTORY_CAPACITY, + pickedIndexAtFraction, + sampleIndexAtFraction, + type HistorySample, +} from "@/domain/sample-history"; import { AreaLayer, Baseline, ScrubBand } from "@/components/metrics/area-layer"; const PEAK = 88; const BASELINE_Y = 99.5; -// Keep page-sized memory wobble from turning into a dramatic spike. -const MIN_SPAN_BYTES = 16 * 1024 * 1024; +// Keeps tiny process footprints from drawing as a full-height graph. +const AXIS_FLOOR_BYTES = 64 * 1024 * 1024; -/** Floating-axis memory trend for one process or group. */ +/** Zero-anchored memory trend for one process or group. */ 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; + const { offset, runs } = scalarGraphScale(history, { + axisFloor: AXIS_FLOOR_BYTES, + peak: PEAK, + }); - const values = history.filter((sample): sample is number => sample !== null); - const max = values.length > 0 ? Math.max(...values) : 0; - const min = values.length > 0 ? Math.min(...values) : 0; - const base = Math.max(0, min - MIN_SPAN_BYTES * 0.25); - 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 +56,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/metrics/network-graph.tsx b/src/renderer/components/metrics/network-graph.tsx index b7b933d..43160e7 100644 --- a/src/renderer/components/metrics/network-graph.tsx +++ b/src/renderer/components/metrics/network-graph.tsx @@ -1,11 +1,11 @@ import { useRef, type PointerEvent as ReactPointerEvent } from "react"; -import { areaRuns } from "@/domain/area-path"; +import { networkGraphScale, type NetworkThroughputSample } from "@/domain/graph-scale"; import { HISTORY_CAPACITY, sampleIndexAtFraction } from "@/domain/sample-history"; import { AreaLayer, Baseline, ScrubBand } from "@/components/metrics/area-layer"; /** One tick of throughput, or `null` for a tick whose reading was not OK. */ -export type NetSample = { rxBytesPerSec: number; txBytesPerSec: number } | null; +export type NetSample = NetworkThroughputSample | null; const BASELINE = 50; // center y of the 0-100 viewBox const LANE = 46; // max amplitude per direction; keeps peaks off the edges @@ -29,15 +29,11 @@ export function NetworkGraph({ onScrub: (index: number | null) => void; }) { const ref = useRef(null); - const offset = HISTORY_CAPACITY - history.length; - - const axisMax = Math.max( - AXIS_FLOOR, - ...history.flatMap((sample) => (sample ? [sample.rxBytesPerSec, sample.txBytesPerSec] : [])), - ); - const amplitude = (bytesPerSec: number) => Math.sqrt(Math.min(1, bytesPerSec / axisMax)) * LANE; - const down = areaRuns(history, offset, (sample) => amplitude(sample.rxBytesPerSec), BASELINE, -1); - const up = areaRuns(history, offset, (sample) => amplitude(sample.txBytesPerSec), BASELINE, 1); + const { offset, down, up } = networkGraphScale(history, { + axisFloor: AXIS_FLOOR, + lane: LANE, + baseline: BASELINE, + }); const handleMove = (event: ReactPointerEvent) => { const rect = ref.current?.getBoundingClientRect(); diff --git a/src/renderer/components/processes/process-detail-fields.tsx b/src/renderer/components/processes/process-detail-fields.tsx new file mode 100644 index 0000000..4730b7a --- /dev/null +++ b/src/renderer/components/processes/process-detail-fields.tsx @@ -0,0 +1,132 @@ +import { Clock, Cpu, User } from "lucide-react"; +import { type ReactNode } from "react"; + +import { CopyButton } from "@/components/processes/disclosure"; +import { ScrollFade } from "@/components/processes/scroll-fade"; +import type { DetailField, DetailState, ProcessDetail } from "@/domain/process-detail"; +import { UNAVAILABLE_TEXT } from "@/lib/format"; +import { cn } from "@/lib/utils"; + +/** The secondary-stat strip under the header: user, threads, CPU time. */ +export function HeaderStats({ detail, grouped }: { detail: ProcessDetail; grouped: boolean }) { + const threadsText = + detail.threadCount.state === "ok" + ? `${detail.threadCount.text} ${detail.threadCount.text === "1" ? "thread" : "threads"}` + : undefined; + + return ( +
+
+ ); +} + +/** One stat in the header strip: a small icon plus its value. */ +function HeaderStat({ + icon, + state, + text, + label, + className, + valueClassName, +}: { + icon: ReactNode + state: DetailState + text?: string + label: string + className?: string + valueClassName?: string +}) { + const value = state === "ok" && text !== undefined ? text : "n/a"; + return ( + + {icon} + + {value} + + + ); +} + +/** A labeled detail field: a quiet uppercase label, the value below. */ +export function Field({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
+ {label} +
+
{children}
+
+ ); +} + +/** Renders a value's pending/unavailable state, or the provided OK text. */ +export function StateText({ state, text }: { state: DetailState; text?: string }) { + if (state === "ok" && text !== undefined) { + return {text}; + } + return ( + + {state === "pending" ? "--" : UNAVAILABLE_TEXT} + + ); +} + +/** Long single-line value (path, command line) with a copy button routed through main. */ +export function ScrollableValue({ + field, + copyLabel, + emptyText = UNAVAILABLE_TEXT, + pendingText = "--", +}: { + field: DetailField + copyLabel: string + emptyText?: string + pendingText?: string +}) { + const text = field.state === "ok" ? field.text ?? "" : undefined; + + if (text === undefined || text.length === 0) { + const placeholder = + text !== undefined ? emptyText : field.state === "pending" ? pendingText : UNAVAILABLE_TEXT; + return {placeholder}; + } + + return ( +
+ + + {text} + + + +
+ ); +} diff --git a/src/renderer/components/processes/process-detail-graph.tsx b/src/renderer/components/processes/process-detail-graph.tsx new file mode 100644 index 0000000..380e69f --- /dev/null +++ b/src/renderer/components/processes/process-detail-graph.tsx @@ -0,0 +1,233 @@ +import { ChevronRight, Cpu, MemoryStick } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { CpuGraph } from "@/components/metrics/cpu-graph"; +import { MemoryGraph } from "@/components/metrics/memory-graph"; +import { MetricRowHeader, ValueUnit } from "@/components/metrics/metric-row-header"; +import { MemberRow } from "@/components/processes/member-row"; +import { useOrderPin } from "@/components/processes/use-order-pin"; +import { memberKey, rankMemberSamples, type DetailMember, type ProcessDetail } from "@/domain/process-detail"; +import { + metricValueText, + type IconTable, + type MemberMetricSample, + type SortMode, +} from "@/domain/process-list"; +import { type HistorySample } from "@/domain/sample-history"; +import { formatBytes, formatCpuPercentPrecise } from "@/lib/format"; +import { cn } from "@/lib/utils"; + +/** Human label for the group total under the active metric. */ +const TOTAL_LABEL: Record = { + cpu: "CPU", + memory: "RAM", +}; + +/** + * 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 detail view lifts it into a top overlay. + */ +export function ProcessDetailGraph({ + 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; + const fillGraph = !canPin; + 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, while + * `onPick` holds a tick when the members list is open. + */ +function ProcessMetricGraph({ + detail, + history, + scrubIndex, + pinned, + divider = true, + fill = false, + onScrub, + onPick, +}: { + detail: ProcessDetail + history: HistorySample[] + scrubIndex: number | null + pinned: boolean + divider?: boolean + fill?: boolean + onScrub: (index: number | null) => void + onPick?: (index: number | null) => void +}) { + const isCpu = detail.totalSort === "cpu"; + const format = isCpu ? formatCpuPercentPrecise : (value: number) => formatBytes(value, true); + const scrubbed = scrubIndex !== null ? history[scrubIndex] ?? null : null; + const valueText = scrubIndex !== null + ? scrubbed !== null + ? format(scrubbed) + : "--" + : detail.totalValue !== null + ? format(detail.totalValue) + : metricValueText(detail.total.state, detail.total.text); + + return ( +
+ + + +
+ {isCpu ? ( + + ) : ( + + )} +
+
+ ); +} + +/** Members list (shown when the disclosure is open), ranked by the active metric. */ +function Members({ + liveMembers, + memberHistory, + pinnedIndex, + sort, + icons, + resetKey, + onOpenMember, +}: { + liveMembers: DetailMember[] + memberHistory: MemberMetricSample[][] + pinnedIndex: number | null + sort: SortMode + icons: IconTable + resetKey: string + onOpenMember: (pid: number, startedAtUnixMs?: number) => void +}) { + const [pointerInside, setPointerInside] = useState(false); + const [focusInside, setFocusInside] = useState(false); + + const tick = pinnedIndex !== null ? memberHistory[pinnedIndex] : undefined; + const showingTick = pinnedIndex !== null && tick !== undefined; + const rankedMembers = useMemo( + () => (showingTick ? rankMemberSamples(tick, sort, icons) : liveMembers), + [showingTick, tick, sort, icons, liveMembers], + ); + + // Suspended while showing a held tick (rows already frozen). + const ordered = useOrderPin( + rankedMembers, + memberKey, + !showingTick && (pointerInside || focusInside), + resetKey, + ); + const members = showingTick ? rankedMembers : ordered; + + return ( +
    setPointerInside(true)} + onPointerLeave={() => setPointerInside(false)} + onFocusCapture={() => setFocusInside(true)} + onBlurCapture={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setFocusInside(false); + } + }} + > + {members.map((member) => ( +
  • + +
  • + ))} +
+ ); +} diff --git a/src/renderer/components/processes/process-detail.tsx b/src/renderer/components/processes/process-detail.tsx index a66260d..5775d0d 100644 --- a/src/renderer/components/processes/process-detail.tsx +++ b/src/renderer/components/processes/process-detail.tsx @@ -1,34 +1,19 @@ -import { ChevronLeft, ChevronRight, Clock, Cpu, MemoryStick, User } from "lucide-react"; -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { ChevronLeft } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } 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 { MemberRow } from "@/components/processes/member-row"; import { ProcessActions } from "@/components/processes/process-actions"; -import { ScrollFade } from "@/components/processes/scroll-fade"; +import { Field, HeaderStats, ScrollableValue, StateText } from "@/components/processes/process-detail-fields"; +import { ProcessDetailGraph } from "@/components/processes/process-detail-graph"; 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 { 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 { - DetailField, - DetailMember, - DetailState, - ProcessDetail, -} from "@/domain/process-detail"; - -/** Human label for the group total under the active metric. */ -const TOTAL_LABEL: Record = { - cpu: "CPU", - memory: "RAM", -}; +import { ScrollFade } from "@/components/processes/scroll-fade"; +import { useMembersOverlay } from "@/components/processes/use-members-overlay"; +import type { ProcessActionKind, ActionState } from "@/gen/process_explorer"; +import type { ProcessDetail } from "@/domain/process-detail"; +import { type IconTable, type MemberMetricSample, type SortMode } from "@/domain/process-list"; +import { type HistorySample } from "@/domain/sample-history"; +import { formatStartTime } from "@/lib/format"; +import { cn } from "@/lib/utils"; /** * The process detail view for one selected group (or one drilled-in process): @@ -44,6 +29,8 @@ const TOTAL_LABEL: Record = { export function ProcessDetailView({ detail, history, + memberHistory, + icons, sort, actions, actionsBusy, @@ -52,9 +39,14 @@ export function ProcessDetailView({ onBack, onOpenMember, onRunAction, + onInspectingChange, + initialMembersOpen = false, + onMembersOpenChange, }: { detail: ProcessDetail history: HistorySample[] + memberHistory: MemberMetricSample[][] + icons: IconTable sort: SortMode actions: ActionState[] actionsBusy: boolean @@ -63,18 +55,57 @@ export function ProcessDetailView({ onBack: () => void onOpenMember: (pid: number, startedAtUnixMs?: number) => void onRunAction: (kind: ProcessActionKind) => void + onInspectingChange?: (inspecting: boolean) => void + initialMembersOpen?: boolean + onMembersOpenChange?: (open: 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)); + }, []); + const clearInspection = useCallback(() => { + setPinned(null); + setScrubIndex(null); + }, []); + + // 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. + const canPin = grouped || pinned !== null; + const { + closing, + contentRef, + inflowGraphRef, + overlayMounted, + overlayStyle, + toggleMembers, + onOverlayAnimationEnd, + } = useMembersOverlay({ + initialOpen: initialMembersOpen, + canShow: canPin, + forceClosed: !grouped && pinned === null, + onCloseMembers: clearInspection, + onOpenChange: onMembersOpenChange, + }); + const headingRef = useRef(null); useEffect(() => { headingRef.current?.focus(); @@ -82,7 +113,7 @@ export function ProcessDetailView({ return (
-
+
-
-
- -
- -

- {detail.name} -

-
- -

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

-
-
-
- - - - {detail.system ? null : ( -
+
+
+
+ +
+ +

+ {detail.name} +

+
+ +

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

+
+
+
+ + + +
- )} - - - - {grouped ? ( - - ) : null} -
- - {detail.system ? null : ( - - )} -
- ); -} -/** Recent trend for the selected process or group under the active metric. */ -function ProcessMetricGraph({ detail, history }: { detail: ProcessDetail; history: HistorySample[] }) { - const isCpu = detail.totalSort === "cpu"; - const [scrubIndex, setScrubIndex] = useState(null); - - const format = isCpu ? formatCpuPercentPrecise : (value: number) => formatBytes(value, true); - - const scrubbed = scrubIndex !== null ? history[scrubIndex] ?? null : null; - const valueText = scrubIndex !== null - ? scrubbed !== null - ? format(scrubbed) - : "--" - : detail.totalValue !== null - ? 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)} - +
+ +
+
+ + {overlayMounted ? ( +
+ +
) : 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. - */ -function HeaderStats({ detail, grouped }: { detail: ProcessDetail; grouped: boolean }) { - const threadsText = - detail.threadCount.state === "ok" - ? `${detail.threadCount.text} ${detail.threadCount.text === "1" ? "thread" : "threads"}` - : undefined; - - return ( -
- {detail.system ? ( -
); } - -/** One stat in the {@link HeaderStats} strip: a small icon plus its value. */ -function HeaderStat({ - icon, - state, - text, - label, - className, - valueClassName, -}: { - icon: ReactNode - state: DetailState - text?: string - label: string - className?: string - valueClassName?: string -}) { - const value = state === "ok" && text !== undefined ? text : "n/a"; - return ( - - {icon} - - {value} - - - ); -} - -/** A labeled detail field: a quiet uppercase label, the value below. */ -function Field({ label, children }: { label: string; children: ReactNode }) { - return ( -
-
- {label} -
-
{children}
-
- ); -} - -/** Renders a value's pending/unavailable state, or the provided OK text. */ -function StateText({ state, text }: { state: DetailState; text?: string }) { - if (state === "ok" && text !== undefined) { - return {text}; - } - return ( - - {state === "pending" ? "--" : UNAVAILABLE_TEXT} - - ); -} - -/** - * A long single-line value (executable path, command line) scrolling - * horizontally in a hidden-scrollbar lane rather than wrapping, with a copy - * button when a real value exists. Sensitive process text is copied only on - * explicit user action and routes through main (the renderer is sandboxed). - */ -function ScrollableValue({ - field, - copyLabel, - emptyText = UNAVAILABLE_TEXT, - pendingText = "--", -}: { - field: DetailField - copyLabel: string - emptyText?: string - pendingText?: string -}) { - const text = field.state === "ok" ? field.text ?? "" : undefined; - - if (text === undefined || text.length === 0) { - const placeholder = - text !== undefined ? emptyText : field.state === "pending" ? pendingText : UNAVAILABLE_TEXT; - return {placeholder}; - } - - return ( -
- - - {text} - - - -
- ); -} - -/** - * The expandable Members section for a multi-process app. Rows are ranked by - * the active metric and drill into individual process details. - */ -function Members({ - members: rankedMembers, - memberCount, - resetKey, - onOpenMember, -}: { - members: DetailMember[] - memberCount: number - /** Changes when the drilled target or sort changes, dropping any stale pin. */ - resetKey: string - onOpenMember: (pid: number, startedAtUnixMs?: number) => void -}) { - const [expanded, setExpanded] = useState(true); - const [pointerInside, setPointerInside] = useState(false); - const [focusInside, setFocusInside] = useState(false); - const members = useOrderPin(rankedMembers, memberKey, pointerInside || focusInside, resetKey); - - return ( -
- - - -
    setPointerInside(true)} - onPointerLeave={() => setPointerInside(false)} - onFocusCapture={() => setFocusInside(true)} - onBlurCapture={(event) => { - if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { - setFocusInside(false); - } - }} - > - {members.map((member) => ( -
  • - -
  • - ))} -
-
-
- ); -} diff --git a/src/renderer/components/processes/process-explorer-view.tsx b/src/renderer/components/processes/process-explorer-view.tsx index ba2c167..1305717 100644 --- a/src/renderer/components/processes/process-explorer-view.tsx +++ b/src/renderer/components/processes/process-explorer-view.tsx @@ -37,6 +37,39 @@ export function ProcessExplorerView({ active }: { active: boolean }) { // stays live), and Back pops one level. const [selectionStack, setSelectionStack] = useState([]); + // Keys of list groups expanded to show their members inline. Owned here, not + // in ProcessList, so it survives a drill-in/Back round trip (which unmounts + // the list). A key whose group has vanished is simply never read. + const [expandedKeys, setExpandedKeys] = useState>(() => new Set()); + const toggleExpanded = useCallback((key: string) => { + setExpandedKeys((current) => { + const next = new Set(current); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }, []); + + // Detail keys whose Members disclosure is open. + const [membersOpenKeys, setMembersOpenKeys] = useState>(() => new Set()); + const setMembersOpen = useCallback((key: string, open: boolean) => { + setMembersOpenKeys((current) => { + if (current.has(key) === open) { + return current; + } + const next = new Set(current); + if (open) { + next.add(key); + } else { + next.delete(key); + } + return next; + }); + }, []); + // Highest revision applied, so an out-of-order pull cannot show stale rows. const appliedRevision = useRef(0); const snapshotRef = useRef(snapshot); @@ -123,7 +156,10 @@ export function ProcessExplorerView({ active }: { active: boolean }) { return undefined; }, [snapshot, sort, selectionStack]); - const readHistory = useProcessHistories(snapshot); + // True while the detail view is inspecting a past tick; freezes history so + // the inspected tick cannot scroll off the graph (see useProcessHistories). + const [inspecting, setInspecting] = useState(false); + const readHistory = useProcessHistories(snapshot, inspecting); const { actions, actionsBusy, actionMessage, runAction } = useProcessActions( detail, pull, @@ -159,12 +195,15 @@ export function ProcessExplorerView({ active }: { active: boolean }) { }, [active, hasDetail, goBack]); if (detail) { + const { history, memberHistory } = readHistory(detail.key, sort); return (
setMembersOpen(detail.key, open)} />
); @@ -185,9 +227,11 @@ export function ProcessExplorerView({ active }: { active: boolean }) { sort={sort} query={query} searchInputRef={searchInputRef} + expandedKeys={expandedKeys} onSortChange={setSort} onQueryChange={setQuery} onOpenSelection={openSelection} + onToggleExpanded={toggleExpanded} /> ); } @@ -198,18 +242,22 @@ function ProcessListPanel({ sort, query, searchInputRef, + expandedKeys, onSortChange, onQueryChange, onOpenSelection, + onToggleExpanded, }: { active: boolean; snapshot: ProcessSnapshot; sort: SortMode; query: string; searchInputRef: RefObject; + expandedKeys: ReadonlySet; onSortChange: (sort: SortMode) => void; onQueryChange: (query: string) => void; onOpenSelection: (selection: DetailSelection) => void; + onToggleExpanded: (key: string) => void; }) { const listRef = useRef(null); const groups = useMemo( @@ -254,7 +302,9 @@ function ProcessListPanel({ icons={snapshot.icons} status={snapshot.status} hasQuery={query.trim().length > 0} + expandedKeys={expandedKeys} onOpenSelection={onOpenSelection} + onToggleExpanded={onToggleExpanded} containerRef={listRef} onExitTop={() => searchInputRef.current?.focus()} /> diff --git a/src/renderer/components/processes/process-icon.tsx b/src/renderer/components/processes/process-icon.tsx index d5ad290..e7337b2 100644 --- a/src/renderer/components/processes/process-icon.tsx +++ b/src/renderer/components/processes/process-icon.tsx @@ -1,28 +1,25 @@ import { useState } from "react"; -import { Box, Cog } from "lucide-react"; +import { Box } from "lucide-react"; import { cn } from "@/lib/utils"; /** * App icon for a process row or the detail header: the base64 PNG when * available, else (or if it fails to decode) a neutral glyph so every row - * keeps the same footprint. The System group gets a gear glyph by design. + * keeps the same footprint. */ export function ProcessIcon({ iconPngBase64, name, size = "sm", - system = false, }: { iconPngBase64?: string name: string size?: "sm" | "lg" - system?: boolean }) { const [failedSrc, setFailedSrc] = useState(undefined); const box = size === "lg" ? "h-9 w-9 rounded-xl" : "h-5 w-5 rounded-lg"; const glyph = size === "lg" ? "h-5 w-5" : "h-3 w-3"; - const Glyph = system ? Cog : Box; if (iconPngBase64 && iconPngBase64 !== failedSrc) { return ( @@ -42,7 +39,7 @@ export function ProcessIcon({ aria-hidden="true" title={name} > - + ); } diff --git a/src/renderer/components/processes/process-list.tsx b/src/renderer/components/processes/process-list.tsx index 54f2a1a..8e97f71 100644 --- a/src/renderer/components/processes/process-list.tsx +++ b/src/renderer/components/processes/process-list.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState, type KeyboardEvent, type Ref } from "react"; +import { useState, type KeyboardEvent, type Ref } from "react"; import { SnapshotStatus } from "@/gen/process_explorer"; import { ProcessRow } from "@/components/processes/process-row"; @@ -28,7 +28,9 @@ export function ProcessList({ icons, status, hasQuery, + expandedKeys, onOpenSelection, + onToggleExpanded, containerRef, onExitTop, }: { @@ -37,26 +39,14 @@ export function ProcessList({ icons: IconTable status: SnapshotStatus hasQuery: boolean + expandedKeys: ReadonlySet onOpenSelection: (selection: DetailSelection) => void + onToggleExpanded: (key: string) => void containerRef?: Ref onExitTop?: () => void }) { const [pointerInside, setPointerInside] = useState(false); const [focusInside, setFocusInside] = useState(false); - // Keys of groups expanded to show their member processes inline. Survives - // snapshot ticks; a key whose group has vanished is simply never read. - const [expandedKeys, setExpandedKeys] = useState>(() => new Set()); - const toggleExpanded = useCallback((key: string) => { - setExpandedKeys((current) => { - const next = new Set(current); - if (next.has(key)) { - next.delete(key); - } else { - next.add(key); - } - return next; - }); - }, []); const pinActive = pointerInside || focusInside; const groups = useOrderPin(rankedGroups, groupKey, pinActive, sort); @@ -115,7 +105,7 @@ export function ProcessList({ expanded={expandedKeys.has(group.key)} pinned={pinActive} onOpen={onOpenSelection} - onToggle={toggleExpanded} + onToggle={onToggleExpanded} /> ))} diff --git a/src/renderer/components/processes/process-row.tsx b/src/renderer/components/processes/process-row.tsx index 50400d2..cf542a5 100644 --- a/src/renderer/components/processes/process-row.tsx +++ b/src/renderer/components/processes/process-row.tsx @@ -93,7 +93,7 @@ export const ProcessRow = memo(function ProcessRow({ title={group.name} className="flex h-full min-w-0 flex-1 items-center gap-2.5 rounded-md pl-6 pr-1 text-left transition-colors hover:bg-muted/50 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring" > - +
void + onOpenChange?: (open: boolean) => void +}) { + const [membersOpen, setMembersOpen] = useState(initialOpen); + const [closing, setClosing] = useState(false); + const [slideFrom, setSlideFrom] = useState(0); + const contentRef = useRef(null); + const inflowGraphRef = useRef(null); + const reportOpen = useRef(onOpenChange); + reportOpen.current = onOpenChange; + + useEffect(() => { + reportOpen.current?.(membersOpen); + }, [membersOpen]); + + 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) { + onCloseMembers(); + } + return !open; + }); + }, [measureSlide, onCloseMembers]); + + const onOverlayAnimationEnd = useCallback( + (event: ReactAnimationEvent) => { + if (event.target === event.currentTarget && closing) { + setClosing(false); + } + }, + [closing], + ); + + useEffect(() => { + if (forceClosed) { + setMembersOpen(false); + setClosing(false); + } + }, [forceClosed]); + + return { + closing, + contentRef, + inflowGraphRef, + overlayMounted: (membersOpen || closing) && canShow, + overlayStyle: { "--pin-from": `${slideFrom}px` } as CSSProperties, + toggleMembers, + onOverlayAnimationEnd, + }; +} diff --git a/src/renderer/components/processes/use-process-actions.ts b/src/renderer/components/processes/use-process-actions.ts index ed8232b..1d7972a 100644 --- a/src/renderer/components/processes/use-process-actions.ts +++ b/src/renderer/components/processes/use-process-actions.ts @@ -34,9 +34,7 @@ export function useProcessActions( onActed: () => Promise, onTerminated: (terminatedPid: number) => void, ): ProcessActionsState { - // The synthetic System group has no single action target (its representative - // would be launchd); the view hides the action row, and no states are fetched. - const targetPid = detail === undefined || detail.system ? undefined : detail.pid; + const targetPid = detail?.pid; const targetStartedAt = detail?.startedAt === "ok" ? detail.startedAtUnixMs : undefined; const target = useMemo(() => { if (targetPid === undefined) { diff --git a/src/renderer/components/processes/use-process-histories.ts b/src/renderer/components/processes/use-process-histories.ts index fc9d3bb..c125919 100644 --- a/src/renderer/components/processes/use-process-histories.ts +++ b/src/renderer/components/processes/use-process-histories.ts @@ -1,25 +1,115 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { ProcessSnapshot } from "@/gen/process_explorer"; -import { sampleMetricsByKey, type SortMode } from "@/domain/process-list"; -import { pushSample, type HistorySample } from "@/domain/sample-history"; +import { + sampleMembers, + sampleMetrics, + type MemberMetricSample, + type SortMode, +} from "@/domain/process-list"; +import { HISTORY_CAPACITY, pushSample, type HistorySample } from "@/domain/sample-history"; -/** A process's retained CPU and memory trails (oldest first). */ +/** + * A process's retained trails (oldest first): the CPU and memory totals that + * drive the graph, plus the per-tick member breakdown behind the detail's + * "members at this tick" list. The three rings are appended in one pass per + * snapshot revision, so a graph scrub index maps 1:1 onto a breakdown entry. + * `members` is empty for a plain singleton process (no breakdown is captured). + */ interface Trails { cpu: HistorySample[]; memory: HistorySample[]; + members: MemberMetricSample[][]; +} + +/** One snapshot tick's readings for a key, the unit appended to the trails. */ +interface TrailSample { + cpu: HistorySample; + memory: HistorySample; + members: MemberMetricSample[]; } /** A read-only view of one target's trail under the active metric. */ export interface ProcessHistory { history: HistorySample[]; + /** + * The per-tick member breakdown, index-aligned with {@link history}, so + * `memberHistory[i]` is the members at the same tick `history[i]` plots. + * Empty for a single-process detail. + */ + memberHistory: MemberMetricSample[][]; +} + +/** Collects each tracked key's reading for one snapshot revision. */ +function sampleTrails(snapshot: ProcessSnapshot, trackedKeys: Set): Map { + const samples = sampleMetrics(snapshot, trackedKeys); + const memberSamples = sampleMembers(snapshot, trackedKeys); + const result = new Map(); + for (const key of trackedKeys) { + const sample = samples.get(key); + if (sample === undefined) { + continue; + } + result.set(key, { cpu: sample.cpu, memory: sample.memory, members: memberSamples.get(key) ?? [] }); + } + return result; +} + +/** Appends one tick onto a key's prior trails, trimming each ring to capacity. */ +function appendTick(prior: Trails | undefined, tick: TrailSample): Trails { + return { + cpu: pushSample(prior?.cpu ?? [], tick.cpu), + memory: pushSample(prior?.memory ?? [], tick.memory), + members: pushSample(prior?.members ?? [], tick.members), + }; } -/** Keeps short CPU/memory trails for process details the user has opened. */ -export function useProcessHistories(snapshot: ProcessSnapshot): (key: string, sort: SortMode) => ProcessHistory { +/** + * Keeps short CPU/memory trails for process details the user has opened. + * + * While `frozen` (the detail view is inspecting a past tick), incoming ticks + * are buffered aside instead of appended, so the inspected tick cannot scroll + * off the graph and no data is lost. When inspection ends the buffered ticks + * are spliced back on in arrival order, capacity-trimmed - a seamless catch-up + * rather than a gap. Main keeps collecting throughout; only this renderer-side + * ring is held. + */ +export function useProcessHistories( + snapshot: ProcessSnapshot, + frozen: boolean, +): (key: string, sort: SortMode) => ProcessHistory { const [trailsByKey, setTrailsByKey] = useState>(() => new Map()); const trackedKeys = useRef(new Set()); const lastRevision = useRef(null); + const frozenRef = useRef(frozen); + // Ticks that arrived while frozen, per key, oldest first - replayed on thaw. + const buffer = useRef(new Map()); + + useEffect(() => { + const wasFrozen = frozenRef.current; + frozenRef.current = frozen; + if (frozen || !wasFrozen || buffer.current.size === 0) { + return; + } + const queued = buffer.current; + buffer.current = new Map(); + setTrailsByKey((previous) => { + const next = new Map(previous); + for (const key of trackedKeys.current) { + const ticks = queued.get(key); + if (ticks === undefined || ticks.length === 0) { + continue; + } + let trails: Trails | undefined = next.get(key); + for (const tick of ticks) { + trails = appendTick(trails, tick); + } + // ticks is non-empty, so the loop ran and trails is now defined. + next.set(key, trails as Trails); + } + return next; + }); + }, [frozen]); useEffect(() => { if (lastRevision.current === snapshot.revision) { @@ -29,24 +119,33 @@ export function useProcessHistories(snapshot: ProcessSnapshot): (key: string, so if (trackedKeys.current.size === 0) { return; } - const samples = sampleMetricsByKey(snapshot); + const ticks = sampleTrails(snapshot, trackedKeys.current); + + if (frozenRef.current) { + // Leave trails untouched so the inspected tick stays stable; stash this + // tick instead, capped per key so a long freeze still bounds memory. + for (const [key, tick] of ticks) { + const queued = buffer.current.get(key) ?? []; + queued.push(tick); + buffer.current.set(key, queued.slice(-HISTORY_CAPACITY)); + } + return; + } + + // Live: drop tracked keys whose target has vanished, then append. for (const key of trackedKeys.current) { - if (!samples.has(key)) { + if (!ticks.has(key)) { trackedKeys.current.delete(key); } } setTrailsByKey((previous) => { const next = new Map(); for (const key of trackedKeys.current) { - const sample = samples.get(key); - if (sample === undefined) { + const tick = ticks.get(key); + if (tick === undefined) { continue; } - const prior = previous.get(key); - next.set(key, { - cpu: pushSample(prior?.cpu ?? [], sample.cpu), - memory: pushSample(prior?.memory ?? [], sample.memory), - }); + next.set(key, appendTick(previous.get(key), tick)); } return next; }); @@ -56,7 +155,10 @@ export function useProcessHistories(snapshot: ProcessSnapshot): (key: string, so (key: string, sort: SortMode): ProcessHistory => { trackedKeys.current.add(key); const trails = trailsByKey.get(key); - return { history: (sort === "cpu" ? trails?.cpu : trails?.memory) ?? [] }; + return { + history: (sort === "cpu" ? trails?.cpu : trails?.memory) ?? [], + memberHistory: trails?.members ?? [], + }; }, [trailsByKey], ); diff --git a/src/renderer/domain/graph-scale.ts b/src/renderer/domain/graph-scale.ts new file mode 100644 index 0000000..1a8ce20 --- /dev/null +++ b/src/renderer/domain/graph-scale.ts @@ -0,0 +1,120 @@ +import { areaRuns, type AreaRun } from "@/domain/area-path"; +import { HISTORY_CAPACITY, type HistorySample } from "@/domain/sample-history"; + +export interface NetworkThroughputSample { + rxBytesPerSec: number; + txBytesPerSec: number; +} + +interface ScalarGraphScaleOptions { + /** Smallest top of the zero-anchored y-axis. */ + axisFloor: number; + /** ViewBox units used by the largest sample. */ + peak: number; + /** Optional visible floor for non-zero samples. */ + minAmplitude?: number; + /** Area fill baseline in the graph viewBox. */ + baseline?: number; + direction?: 1 | -1; + capacity?: number; +} + +interface ScalarGraphScale { + offset: number; + runs: AreaRun[]; +} + +interface NetworkGraphScaleOptions { + /** Smallest top of the shared zero-anchored throughput axis. */ + axisFloor: number; + /** ViewBox units available above/below the mirrored baseline. */ + lane: number; + baseline?: number; + capacity?: number; +} + +interface NetworkGraphScale { + offset: number; + down: AreaRun[]; + up: AreaRun[]; +} + +/** Largest visible sample with a zero-anchored floor. */ +function scalarAxisMax(history: HistorySample[], floor: number): number { + const max = history.reduce( + (peak, sample) => (sample === null || !Number.isFinite(sample) || sample <= 0 ? peak : Math.max(peak, sample)), + 0, + ); + return Math.max(floor, max); +} + +/** Maps a scalar sample onto a zero-anchored 0..1 graph domain. */ +function scalarGraphRatio(value: number, axisMax: number): number { + if (!Number.isFinite(value) || !Number.isFinite(axisMax) || value <= 0 || axisMax <= 0) return 0; + return Math.min(1, value / axisMax); +} + +/** ViewBox-height amplitude for one zero-anchored scalar sample. */ +function scalarGraphAmplitude(value: number, axisMax: number, peak: number, minAmplitude = 0): number { + if (!Number.isFinite(value) || value < 0) return 0; + const amplitude = scalarGraphRatio(value, axisMax) * peak; + return minAmplitude > 0 ? Math.max(minAmplitude, amplitude) : amplitude; +} + +/** Shared zero-anchored area graph geometry for scalar CPU/memory histories. */ +export function scalarGraphScale( + history: HistorySample[], + { + axisFloor, + peak, + minAmplitude = 0, + baseline = 100, + direction = -1, + capacity = HISTORY_CAPACITY, + }: ScalarGraphScaleOptions, +): ScalarGraphScale { + const axisMax = scalarAxisMax(history, axisFloor); + const offset = capacity - history.length; + const runs = areaRuns( + history, + offset, + (sample) => scalarGraphAmplitude(sample, axisMax, peak, minAmplitude), + baseline, + direction, + ); + + return { offset, runs }; +} + +/** Shared rx/tx throughput axis so both network directions stay comparable. */ +function networkAxisMax(history: (NetworkThroughputSample | null)[], floor: number): number { + const max = history.reduce((peak, sample) => { + if (sample === null) return peak; + return Math.max(peak, positiveFinite(sample.rxBytesPerSec), positiveFinite(sample.txBytesPerSec)); + }, 0); + return Math.max(floor, max); +} + +/** Square-root compression keeps bursts visible without flattening routine traffic. */ +function networkGraphAmplitude(bytesPerSec: number, axisMax: number, lane: number): number { + return Math.sqrt(scalarGraphRatio(bytesPerSec, axisMax)) * lane; +} + +/** Mirrored zero-anchored geometry for network download/upload histories. */ +export function networkGraphScale( + history: (NetworkThroughputSample | null)[], + { axisFloor, lane, baseline = 50, capacity = HISTORY_CAPACITY }: NetworkGraphScaleOptions, +): NetworkGraphScale { + const axisMax = networkAxisMax(history, axisFloor); + const offset = capacity - history.length; + + return { + offset, + down: areaRuns(history, offset, (sample) => networkGraphAmplitude(sample.rxBytesPerSec, axisMax, lane), baseline, -1), + up: areaRuns(history, offset, (sample) => networkGraphAmplitude(sample.txBytesPerSec, axisMax, lane), baseline, 1), + }; +} + +function positiveFinite(value: number): number { + return Number.isFinite(value) && value > 0 ? value : 0; +} diff --git a/src/renderer/domain/process-detail.ts b/src/renderer/domain/process-detail.ts index 187e507..6dd3d45 100644 --- a/src/renderer/domain/process-detail.ts +++ b/src/renderer/domain/process-detail.ts @@ -13,6 +13,7 @@ import { rowPid, rowStartedAt, type IconTable, + type MemberMetricSample, type MetricCell, type ProcessGroup, type ProcessMetricState, @@ -102,12 +103,6 @@ export interface ProcessDetail { * by the active metric). Empty for a single-process detail. */ members: DetailMember[]; - /** - * True only for the synthetic System group: the view shows the member count - * instead of one process's identity and hides the single-process fields and - * the action row, while the summed stats and member list still apply. - */ - system: boolean; /** True when macOS marks any member app Not Responding (see ProcessGroup). */ notResponding: boolean; } @@ -239,6 +234,36 @@ export function rankMembers(group: ProcessGroup, sort: SortMode, icons: IconTabl .map((row) => buildMember(row, sort, icons)); } +/** + * Ranks a stored per-tick member breakdown into display rows under the active + * sort, mirroring {@link rankMembers} but for a historical tick. + */ +export function rankMemberSamples( + samples: MemberMetricSample[], + sort: SortMode, + icons: IconTable, +): DetailMember[] { + return samples + .slice() + .sort((left, right) => { + const delta = (right[sort] ?? 0) - (left[sort] ?? 0); + return delta !== 0 ? delta : left.pid - right.pid; + }) + .map((sample) => { + const value = sample[sort]; + const hasValue = value !== null; + return { + pid: sample.pid, + startedAtUnixMs: sample.startedAtUnixMs, + name: sample.name, + iconPngBase64: sample.iconKey ? icons[sample.iconKey] || undefined : undefined, + metricState: hasValue ? "ok" : "unavailable", + metricText: hasValue ? formatDetailMetric(value, sort) : undefined, + notResponding: false, + } satisfies DetailMember; + }); +} + /** * Projects a selected {@link ProcessGroup} into its display model. Identity, * path, argv, and started-at come from the representative (the row the @@ -280,7 +305,6 @@ export function buildProcessDetail(group: ProcessGroup, sort: SortMode, icons: I totalSort: sort, memberCount: group.memberCount, members, - system: group.system, notResponding: group.notResponding, }; } diff --git a/src/renderer/domain/process-list.ts b/src/renderer/domain/process-list.ts index 6c90325..df5e0e9 100644 --- a/src/renderer/domain/process-list.ts +++ b/src/renderer/domain/process-list.ts @@ -4,8 +4,6 @@ import { UNAVAILABLE_TEXT, formatBytes, formatCpuPercent } from "@/lib/format"; /** * Pure presentation logic for the process explorer list: turns a raw * {@link ProcessSnapshot} into ranked, searchable, app-grouped display rows. - * Side-effect free and OS/IPC-agnostic; the detail model in - * {@link "@/domain/process-detail"} builds on the groups and row readers here. * * Privacy: command-line arguments are used only as a local in-memory search * haystack; they are never emitted into display fields, logged, or persisted. @@ -63,11 +61,6 @@ export interface ProcessGroup { * opened detail. */ openSelection: DetailSelection; - /** - * True only for the synthetic System group: it gets the gear glyph and a - * member-count subtitle, and hides single-process fields and actions. - */ - system: boolean; /** * True when macOS marks any member app Not Responding. In practice only an * app's main process carries the window-server flag, so this is the app @@ -221,41 +214,16 @@ export function rowIdentityKey(row: ProcessRow): string { } /** - * Key of the synthetic System group that buckets Apple's non-app system - * processes (daemons under SIP-protected paths) into one compact row, keeping - * the list focused on the user's own apps instead of idle macOS daemons. - */ -export const SYSTEM_GROUP_KEY = "system"; - -/** - * Apple-owned executable locations - the SIP-protected prefixes. `/usr/local/` - * is deliberately excluded: it is the user-writable exception where developer - * tools live, exactly the processes this product surfaces individually. - */ -const SYSTEM_PATH_PREFIXES = ["/System/", "/usr/", "/sbin/", "/bin/"]; - -function isSystemPath(path: string): boolean { - if (path.startsWith("/usr/local/")) { - return false; - } - return SYSTEM_PATH_PREFIXES.some((prefix) => path.startsWith(prefix)); -} - -/** - * Group key: an owning `.app` path groups an app's processes; a non-app - * process in an Apple-owned path joins the System group; everything else - - * including a row with no readable path, whose identity is uncertain - stays - * a singleton. + * Group key: an owning `.app` path groups an app's processes; everything else - + * a non-app process (including macOS daemons and any row with no readable path, + * whose identity is uncertain) - stays its own singleton row. The list shows + * every running process; nothing is hidden. */ function rowGroupKey(row: ProcessRow): string { const bundlePath = okString(row.statics?.app?.bundle?.path); if (bundlePath) { return `app:${bundlePath}`; } - const path = okString(row.statics?.executablePath); - if (path !== undefined && isSystemPath(path)) { - return SYSTEM_GROUP_KEY; - } return rowIdentityKey(row); } @@ -361,20 +329,18 @@ function representativeOf(members: ProcessRow[]): ProcessRow { */ function buildGroupRow(group: GroupAccumulator, sort: SortMode, icons: IconTable): ProcessGroup { const representative = representativeOf(group.members); - const isSystem = group.key === SYSTEM_GROUP_KEY; const metricState: ProcessMetricState = group.hasMetric ? "ok" : group.anyPending ? "pending" : "unavailable"; const icon = rowIcon(representative, icons) ?? group.members.map((row) => rowIcon(row, icons)).find(Boolean); // A multi-process group shows the owning `.app` name; a single process shows - // its own display name. The System group shows its fixed label and the gear - // glyph (no member's executable icon should brand the whole bucket). + // its own display name. const appName = group.members.length > 1 ? okString(representative.statics?.app?.bundle?.name) : undefined; return { key: group.key, - name: isSystem ? "System" : appName ?? rowDisplayName(representative), + name: appName ?? rowDisplayName(representative), pid: rowPid(representative), - iconPngBase64: isSystem ? undefined : icon, + iconPngBase64: icon, memberCount: group.members.length, childCount: group.members.length - 1, metricState, @@ -382,7 +348,6 @@ function buildGroupRow(group: GroupAccumulator, sort: SortMode, icons: IconTable sortValue: group.sortValueSum, openSelection: { kind: "group", key: group.key }, members: representativeFirst(group.members, representative), - system: isSystem, notResponding: group.anyNotResponding, }; } @@ -408,9 +373,7 @@ function buildGroupedRows(rows: ProcessRow[], sort: SortMode, icons: IconTable): /** * Builds search results from already-grouped rows. App identity or * representative matches keep the group whole; otherwise only the matching - * member processes are shown, as singletons. The representative shortcut is - * skipped for the System group: its members are unrelated daemons, so a match - * (e.g. "launchd") surfaces that daemon, not the whole bucket. + * member processes are shown, as singletons. */ function buildSearchGroups( groups: ProcessGroup[], @@ -424,7 +387,7 @@ function buildSearchGroups( const representative = group.members[0]; if ( groupHaystack(group).includes(query) || - (!group.system && rowHaystack(representative).includes(query)) + rowHaystack(representative).includes(query) ) { projected.push(group); continue; @@ -506,8 +469,96 @@ function addSample(current: number | null, cell: MetricCell): number | null { return (current ?? 0) + cell.value; } -/** Samples graph values under the same keys {@link resolveSelection} uses. */ -export function sampleMetricsByKey(snapshot: ProcessSnapshot): Map { +function shouldSampleKey(keys: ReadonlySet | undefined, key: string): boolean { + return keys === undefined || keys.has(key); +} + +/** + * One member's CPU and memory reading at a single tick, carrying enough + * identity to render the row even after the process has exited (its name and + * icon are statics the renderer may no longer hold). `cpu`/`memory` are `null` + * when that metric was not readable for the member at that tick. + */ +export interface MemberMetricSample { + /** Stable member key ({@link rowIdentityKey}), for React lists and pinning. */ + key: string; + pid: number; + startedAtUnixMs?: number; + /** Display name captured at the tick, so an exited member still labels. */ + name: string; + /** Icon key captured at the tick; resolved through the live icon table. */ + iconKey?: string; + cpu: number | null; + memory: number | null; +} + +/** + * Per-tick member breakdowns for grouped app keys. A one-member app group is + * still captured so a historical tick can honestly show "this app only had one + * process then" instead of falling back to the live multi-process list. Plain + * singleton processes keep no breakdown because the detail already is the row. + */ +function sampleMemberRowsByKey( + snapshot: ProcessSnapshot, + keys?: ReadonlySet, +): Map { + const membersByKey = new Map(); + for (const row of snapshot.processes) { + const key = rowGroupKey(row); + if (!shouldSampleKey(keys, key)) { + continue; + } + const existing = membersByKey.get(key); + if (existing === undefined) { + membersByKey.set(key, [row]); + } else { + existing.push(row); + } + } + return membersByKey; +} + +function buildMemberBreakdowns(membersByKey: Map): Map { + const breakdowns = new Map(); + for (const [key, rows] of membersByKey) { + const isPlainSingleton = rows.length === 1 && rowIdentityKey(rows[0]) === key; + if (isPlainSingleton) { + continue; + } + breakdowns.set( + key, + rows.map((row) => ({ + key: rowIdentityKey(row), + pid: rowPid(row), + startedAtUnixMs: rowStartedAt(row), + name: rowDisplayName(row), + iconKey: row.statics?.app?.iconKey || undefined, + cpu: rowCpu(row).value ?? null, + memory: rowMemory(row).value ?? null, + })), + ); + } + + return breakdowns; +} + +/** + * Per-tick member breakdowns keyed by group. Pass `keys` to build only the + * breakdowns currently tracked detail histories need; omit it to sample every + * group. + */ +export function sampleMembers( + snapshot: ProcessSnapshot, + keys?: ReadonlySet, +): Map { + return buildMemberBreakdowns(sampleMemberRowsByKey(snapshot, keys)); +} + +/** + * Per-tick CPU and memory totals under the same keys {@link resolveSelection} + * uses. Pass `keys` to sample only tracked detail histories; omit it for all. + */ +export function sampleMetrics(snapshot: ProcessSnapshot, keys?: ReadonlySet): Map { const samples = new Map(); const fold = (key: string, row: ProcessRow) => { @@ -519,9 +570,11 @@ export function sampleMetricsByKey(snapshot: ProcessSnapshot): Map { expect(detail.totalValue).toBeNull(); }); }); + +describe("rankMemberSamples - historical tick breakdown", () => { + const sample = (over: Partial & Pick): MemberMetricSample => ({ + key: `pid:${over.pid}:1`, + startedAtUnixMs: 1, + name: `proc-${over.pid}`, + cpu: 0, + memory: 0, + ...over, + }); + + it("ranks a stored tick by the active metric (desc), matching the live list", () => { + const tick = [sample({ pid: 100, cpu: 4, memory: 300 }), sample({ pid: 200, cpu: 8, memory: 150 })]; + expect(rankMemberSamples(tick, "cpu", {}).map((m) => m.pid)).toEqual([200, 100]); + expect(rankMemberSamples(tick, "memory", {}).map((m) => m.pid)).toEqual([100, 200]); + }); + + it("ties break by PID for equal-value members", () => { + const tick = [sample({ pid: 30, cpu: 0 }), sample({ pid: 10, cpu: 0 }), sample({ pid: 20, cpu: 0 })]; + expect(rankMemberSamples(tick, "cpu", {}).map((m) => m.pid)).toEqual([10, 20, 30]); + }); + + it("renders a member whose metric was null at the tick as unavailable", () => { + const tick = [sample({ pid: 100, cpu: null }), sample({ pid: 200, cpu: 5 })]; + const ranked = rankMemberSamples(tick, "cpu", {}); + const nullMember = ranked.find((m) => m.pid === 100); + expect(nullMember?.metricState).toBe("unavailable"); + expect(nullMember?.metricText).toBeUndefined(); + // The member that did read shows its value at detail precision. + expect(ranked.find((m) => m.pid === 200)?.metricText).toBe("5.00%"); + }); + + it("labels an exited member from the name captured at the tick", () => { + // No live snapshot is consulted: the stored name is the only source, so a + // member that has since exited still renders with a real label. + const tick = [sample({ pid: 100, name: "Gone Helper", cpu: 1 }), sample({ pid: 200, name: "Alive", cpu: 2 })]; + expect(rankMemberSamples(tick, "cpu", {}).map((m) => m.name)).toEqual(["Alive", "Gone Helper"]); + }); + + it("resolves a member icon through the live table by captured key, else the glyph", () => { + const tick = [ + sample({ pid: 100, iconKey: "ICON", cpu: 2 }), + sample({ pid: 200, iconKey: "STALE", cpu: 1 }), + ]; + const ranked = rankMemberSamples(tick, "cpu", { ICON: "ICON-BYTES" }); + expect(ranked.find((m) => m.pid === 100)?.iconPngBase64).toBe("ICON-BYTES"); + // A key no longer in the table (the app's icon dropped from cache) -> none. + expect(ranked.find((m) => m.pid === 200)?.iconPngBase64).toBeUndefined(); + }); + + it("never reports a historical member as Not Responding (a transient live state)", () => { + const tick = [sample({ pid: 100, cpu: 1 })]; + expect(rankMemberSamples(tick, "cpu", {})[0].notResponding).toBe(false); + }); +}); diff --git a/tests/unit/process-list.test.ts b/tests/unit/process-list.test.ts index 55fce14..9d90c7a 100644 --- a/tests/unit/process-list.test.ts +++ b/tests/unit/process-list.test.ts @@ -15,9 +15,9 @@ import { rowMetric, rowNotResponding, rowPid, - sampleMetricsByKey, + sampleMembers, + sampleMetrics, singleProcessGroup, - SYSTEM_GROUP_KEY, } from "@/domain/process-list"; import { makeRow, makeSnapshot } from "../helpers/process-fixtures"; @@ -406,82 +406,65 @@ describe("projectProcessList - icon resolution", () => { }); }); -describe("projectProcessList - System group", () => { +describe("projectProcessList - system daemons", () => { + // The list shows every running process; macOS daemons are not bucketed or + // hidden. A non-app process (no .app bundle) is its own singleton row. const daemons = [ makeRow({ pid: 1, commandName: "launchd", startedAtUnixMs: 1, executablePath: "/sbin/launchd", cpuPercent: 0.5 }), makeRow({ pid: 400, commandName: "fake-sharingd", startedAtUnixMs: 2, executablePath: "/usr/libexec/fake-sharingd", cpuPercent: 1 }), makeRow({ pid: 401, commandName: "fake-mds", startedAtUnixMs: 3, executablePath: "/System/Library/fake-mds", cpuPercent: 2 }), ]; - it("buckets Apple-path non-app processes into one System group", () => { + it("shows Apple-path non-app daemons as individual singleton rows", () => { const groups = projectProcessList(makeSnapshot(daemons), "cpu", ""); - - expect(groups).toHaveLength(1); - const system = groups[0]; - expect(system.key).toBe(SYSTEM_GROUP_KEY); - expect(system.system).toBe(true); - expect(system.name).toBe("System"); - // No member's generic executable icon brands the bucket. - expect(system.iconPngBase64).toBeUndefined(); - expect(system.memberCount).toBe(3); - expect(system.metricText).toBe("3.5%"); + expect(groups).toHaveLength(3); + expect(groups.every((g) => g.memberCount === 1)).toBe(true); + // Ranked by CPU descending, like any other rows. + expect(groups.map((g) => g.name)).toEqual(["fake-mds", "fake-sharingd", "launchd"]); }); - it("keeps user-owned and app-bundled processes out of System", () => { + it("lists daemons alongside user and app-bundled processes, nothing hidden", () => { const rows = [ ...daemons, - // /usr/local is the SIP user-writable exception - a developer's tool. makeRow({ pid: 500, commandName: "fake-postgres", startedAtUnixMs: 4, executablePath: "/usr/local/bin/fake-postgres", cpuPercent: 1 }), - // A user CLI outside Apple paths. makeRow({ pid: 501, commandName: "fake-node", startedAtUnixMs: 5, executablePath: "/Users/fixture/work/fake-node", cpuPercent: 1 }), - // An Apple *app* under /System keeps its own app group. + // An app keeps its own app group (it has a bundle); its helper folds in. makeRow({ pid: 502, localizedName: "Fake Dock", startedAtUnixMs: 6, executablePath: "/System/Library/CoreServices/FakeDock.app/Contents/MacOS/FakeDock", bundlePath: "/System/Library/CoreServices/FakeDock.app", bundleName: "Fake Dock", cpuPercent: 1 }), - // A row with no readable path stays a singleton, not buried in System. makeRow({ pid: 503, commandName: "fake-pathless", startedAtUnixMs: 7, cpuPercent: 1 }), ]; const groups = projectProcessList(makeSnapshot(rows), "cpu", ""); const names = groups.map((group) => group.name).sort(); - expect(names).toEqual(["Fake Dock", "System", "fake-node", "fake-pathless", "fake-postgres"]); - expect(groups.filter((group) => group.system)).toHaveLength(1); + expect(names).toEqual([ + "Fake Dock", + "fake-mds", + "fake-node", + "fake-pathless", + "fake-postgres", + "fake-sharingd", + "launchd", + ]); }); - it("search surfaces a matching daemon as its own row, not the whole bucket", () => { + it("surfaces a daemon by name search like any other process", () => { const groups = projectProcessList(makeSnapshot(daemons), "cpu", "fake-sharingd"); - expect(groups).toHaveLength(1); - expect(groups[0].system).toBe(false); expect(groups[0].name).toBe("fake-sharingd"); - expect(groups[0].openSelection).toEqual({ kind: "process", pid: 400, startedAtUnixMs: 2 }); - }); - - it("searching 'system' surfaces the System group itself", () => { - const groups = projectProcessList(makeSnapshot(daemons), "cpu", "system"); - - expect(groups.some((group) => group.key === SYSTEM_GROUP_KEY)).toBe(true); - }); - - it("resolves the System selection from a fresh snapshot like any group", () => { - const resolved = resolveSelection(makeSnapshot(daemons), "cpu", { - kind: "group", - key: SYSTEM_GROUP_KEY, - }); - - expect(resolved?.system).toBe(true); - expect(resolved?.memberCount).toBe(3); - // Representative is the lowest PID, but the display identity stays "System". - expect(resolved?.name).toBe("System"); + // A singleton daemon matches as its own group; the key is its identity key. + expect(groups[0].memberCount).toBe(1); + expect(groups[0].pid).toBe(400); + expect(groups[0].openSelection).toEqual({ kind: "group", key: "pid:400:2" }); }); }); -describe("sampleMetricsByKey", () => { +describe("sampleMetrics", () => { it("sums a group's members under the group key, with per-member identity keys", () => { const rows = [ makeRow({ pid: 100, bundlePath: "/Applications/Chrome.app", startedAtUnixMs: 1, cpuPercent: 4, footprintBytes: 300 * MB }), makeRow({ pid: 200, bundlePath: "/Applications/Chrome.app", startedAtUnixMs: 2, cpuPercent: 8, footprintBytes: 150 * MB }), ]; - const samples = sampleMetricsByKey(makeSnapshot(rows)); + const samples = sampleMetrics(makeSnapshot(rows)); expect(samples.get("app:/Applications/Chrome.app")).toEqual({ cpu: 12, memory: 450 * MB }); expect(samples.get("pid:100:1")).toEqual({ cpu: 4, memory: 300 * MB }); @@ -490,7 +473,7 @@ describe("sampleMetricsByKey", () => { it("keys an ungrouped process once (group key == identity)", () => { const rows = [makeRow({ pid: 321, commandName: "tool", startedAtUnixMs: 5, cpuPercent: 7, footprintBytes: 20 * MB })]; - const samples = sampleMetricsByKey(makeSnapshot(rows)); + const samples = sampleMetrics(makeSnapshot(rows)); expect(samples.get("pid:321:5")).toEqual({ cpu: 7, memory: 20 * MB }); expect(samples.size).toBe(1); @@ -500,9 +483,105 @@ describe("sampleMetricsByKey", () => { const rows = [ makeRow({ pid: 10, commandName: "x", startedAtUnixMs: 1, cpuStatus: FieldStatus.FIELD_STATUS_UNAVAILABLE, footprintBytes: 5 * MB }), ]; - const sample = sampleMetricsByKey(makeSnapshot(rows)).get("pid:10:1"); + const sample = sampleMetrics(makeSnapshot(rows)).get("pid:10:1"); expect(sample?.cpu).toBeNull(); expect(sample?.memory).toBe(5 * MB); }); + + it("can selectively sample only tracked history keys", () => { + const rows = [ + makeRow({ pid: 100, bundlePath: "/Applications/Chrome.app", startedAtUnixMs: 1, cpuPercent: 4, footprintBytes: 300 * MB }), + makeRow({ pid: 200, bundlePath: "/Applications/Chrome.app", startedAtUnixMs: 2, cpuPercent: 8, footprintBytes: 150 * MB }), + makeRow({ pid: 300, bundlePath: "/Applications/Slack.app", startedAtUnixMs: 3, cpuPercent: 2, footprintBytes: 50 * MB }), + ]; + const snapshot = makeSnapshot(rows); + const full = sampleMetrics(snapshot); + const selective = sampleMetrics(snapshot, new Set(["app:/Applications/Chrome.app", "pid:200:2"])); + + expect(selective.get("app:/Applications/Chrome.app")).toEqual(full.get("app:/Applications/Chrome.app")); + expect(selective.get("pid:200:2")).toEqual(full.get("pid:200:2")); + expect(selective.has("pid:100:1")).toBe(false); + expect(selective.has("app:/Applications/Slack.app")).toBe(false); + }); +}); + +describe("sampleMembers", () => { + it("captures a per-member breakdown (both metrics, with identity) under the group key", () => { + const rows = [ + makeRow({ pid: 100, bundlePath: "/Applications/Chrome.app", localizedName: "Chrome", startedAtUnixMs: 1, cpuPercent: 4, footprintBytes: 300 * MB, iconPngBase64: "ICON" }), + makeRow({ pid: 200, bundlePath: "/Applications/Chrome.app", executableName: "Chrome Helper", startedAtUnixMs: 2, cpuPercent: 8, footprintBytes: 150 * MB }), + ]; + const breakdown = sampleMembers(makeSnapshot(rows)).get("app:/Applications/Chrome.app"); + + expect(breakdown).toEqual([ + { key: "pid:100:1", pid: 100, startedAtUnixMs: 1, name: "Chrome", iconKey: "ICON", cpu: 4, memory: 300 * MB }, + { key: "pid:200:2", pid: 200, startedAtUnixMs: 2, name: "Chrome Helper", iconKey: undefined, cpu: 8, memory: 150 * MB }, + ]); + }); + + it("omits single-member keys (an ordinary process has no breakdown)", () => { + const rows = [makeRow({ pid: 321, commandName: "tool", startedAtUnixMs: 5, cpuPercent: 7 })]; + const breakdowns = sampleMembers(makeSnapshot(rows)); + + expect(breakdowns.size).toBe(0); + }); + + it("captures a one-member app group so historical ticks do not fall back to live members", () => { + const rows = [ + makeRow({ + pid: 100, + bundlePath: "/Applications/App.app", + localizedName: "App", + startedAtUnixMs: 1, + cpuPercent: 4, + footprintBytes: 300 * MB, + }), + ]; + const breakdown = sampleMembers(makeSnapshot(rows)).get("app:/Applications/App.app"); + + expect(breakdown).toEqual([ + { key: "pid:100:1", pid: 100, startedAtUnixMs: 1, name: "App", iconKey: undefined, cpu: 4, memory: 300 * MB }, + ]); + }); + + it("records an unreadable member metric as null at the tick", () => { + const rows = [ + makeRow({ pid: 100, bundlePath: "/Applications/App.app", startedAtUnixMs: 1, cpuStatus: FieldStatus.FIELD_STATUS_UNAVAILABLE, footprintBytes: 10 * MB }), + makeRow({ pid: 200, bundlePath: "/Applications/App.app", startedAtUnixMs: 2, cpuPercent: 3, footprintBytes: 20 * MB }), + ]; + const breakdown = sampleMembers(makeSnapshot(rows)).get("app:/Applications/App.app"); + + expect(breakdown?.[0].cpu).toBeNull(); + expect(breakdown?.[0].memory).toBe(10 * MB); + }); + + it("captures only multi-member keys; a singleton daemon gets no breakdown", () => { + const rows = [ + makeRow({ pid: 100, bundlePath: "/Applications/App.app", startedAtUnixMs: 1, cpuPercent: 4 }), + makeRow({ pid: 200, bundlePath: "/Applications/App.app", startedAtUnixMs: 2, cpuPercent: 3 }), + // A lone daemon is a singleton row, so it has no member breakdown (the + // detail just shows the row itself) - it is listed, not hidden. + makeRow({ pid: 1, commandName: "launchd", startedAtUnixMs: 9, executablePath: "/sbin/launchd", cpuPercent: 1 }), + ]; + const breakdowns = sampleMembers(makeSnapshot(rows)); + + expect([...breakdowns.keys()]).toEqual(["app:/Applications/App.app"]); + expect(breakdowns.get("pid:1:9")).toBeUndefined(); + }); + + it("can selectively sample only tracked app-group breakdowns", () => { + const rows = [ + makeRow({ pid: 100, bundlePath: "/Applications/Chrome.app", localizedName: "Chrome", startedAtUnixMs: 1, cpuPercent: 4, footprintBytes: 300 * MB }), + makeRow({ pid: 200, bundlePath: "/Applications/Chrome.app", executableName: "Chrome Helper", startedAtUnixMs: 2, cpuPercent: 8, footprintBytes: 150 * MB }), + makeRow({ pid: 300, bundlePath: "/Applications/Slack.app", localizedName: "Slack", startedAtUnixMs: 3, cpuPercent: 2, footprintBytes: 50 * MB }), + makeRow({ pid: 400, bundlePath: "/Applications/Slack.app", executableName: "Slack Helper", startedAtUnixMs: 4, cpuPercent: 1, footprintBytes: 40 * MB }), + ]; + const snapshot = makeSnapshot(rows); + const full = sampleMembers(snapshot); + const selective = sampleMembers(snapshot, new Set(["app:/Applications/Chrome.app"])); + + expect(selective.get("app:/Applications/Chrome.app")).toEqual(full.get("app:/Applications/Chrome.app")); + expect([...selective.keys()]).toEqual(["app:/Applications/Chrome.app"]); + }); }); diff --git a/tests/unit/sample-history.test.ts b/tests/unit/sample-history.test.ts index fdc7044..d1edca4 100644 --- a/tests/unit/sample-history.test.ts +++ b/tests/unit/sample-history.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { pushSample, sampleIndexAtFraction } from "@/domain/sample-history"; +import { pickedIndexAtFraction, pushSample, sampleIndexAtFraction } from "@/domain/sample-history"; describe("pushSample", () => { it("appends and keeps at most capacity newest entries", () => { @@ -30,3 +30,22 @@ describe("sampleIndexAtFraction", () => { expect(sampleIndexAtFraction(1, 60, 60)).toBe(59); }); }); + +describe("pickedIndexAtFraction", () => { + it("returns null when empty", () => { + expect(pickedIndexAtFraction(0.5, 0)).toBeNull(); + }); + + it("returns null over the unfilled left region instead of clamping (resume gesture)", () => { + // Hover clamps the empty left to the oldest sample; a click there is null, + // so the detail view reads it as "resume live" rather than picking tick 0. + expect(sampleIndexAtFraction(0, 10, 60)).toBe(0); + expect(pickedIndexAtFraction(0, 10, 60)).toBeNull(); + }); + + it("maps a click within the filled region to that sample", () => { + expect(pickedIndexAtFraction(1, 10, 60)).toBe(9); + expect(pickedIndexAtFraction(1, 60, 60)).toBe(59); + expect(pickedIndexAtFraction(0, 60, 60)).toBe(0); + }); +});