Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions src/renderer/components/metrics/area-layer.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 (
<g className={className}>
Expand All @@ -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 <rect x={x} y={0} width={1} height={100} className="text-foreground" fill="currentColor" fillOpacity={0.12} />;
/**
* 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 (
<rect
x={x}
y={0}
width={1}
height={100}
className="text-foreground"
fill="currentColor"
fillOpacity={pinned ? 0.22 : 0.12}
/>
);
}
54 changes: 35 additions & 19 deletions src/renderer/components/metrics/cpu-graph.tsx
Original file line number Diff line number Diff line change
@@ -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<MetricState, string> = {
Expand All @@ -22,49 +26,61 @@ 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<SVGSVGElement>(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<SVGSVGElement>) => {
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 (
<svg
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}
>
<Baseline y={BASELINE_Y} offset={offset} />
<AreaLayer runs={runs} />
{scrubIndex !== null ? <ScrubBand x={offset + scrubIndex} /> : null}
{scrubIndex !== null ? <ScrubBand x={offset + scrubIndex} pinned={pinned} /> : null}
</svg>
);
}
57 changes: 37 additions & 20 deletions src/renderer/components/metrics/memory-graph.tsx
Original file line number Diff line number Diff line change
@@ -1,54 +1,71 @@
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<SVGSVGElement>(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<SVGSVGElement>) => {
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 (
<svg
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}
>
<Baseline y={BASELINE_Y} offset={offset} />
<AreaLayer runs={runs} />
{scrubIndex !== null ? <ScrubBand x={offset + scrubIndex} /> : null}
{scrubIndex !== null ? <ScrubBand x={offset + scrubIndex} pinned={pinned} /> : null}
</svg>
);
}
18 changes: 7 additions & 11 deletions src/renderer/components/metrics/network-graph.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -29,15 +29,11 @@ export function NetworkGraph({
onScrub: (index: number | null) => void;
}) {
const ref = useRef<SVGSVGElement>(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<SVGSVGElement>) => {
const rect = ref.current?.getBoundingClientRect();
Expand Down
Loading
Loading