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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Live macOS system resources at a glance, plus a searchable process explorer.

Built with [MōBrowser](https://teamdev.com/mobrowser/), React, and TypeScript, with a small native module for the metrics.

https://github.com/user-attachments/assets/5c2ef538-3f27-4711-8f60-ac12e7706483

## What it does

- **System overview.** CPU, memory, network, disk, uptime, and CPU temperature.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ export function ProcessExplorerView({ active }: { active: boolean }) {
// 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 readHistory = useProcessHistories(snapshot, inspecting, detail?.key);
const { actions, actionsBusy, actionMessage, runAction } = useProcessActions(
detail,
pull,
Expand Down
160 changes: 132 additions & 28 deletions src/renderer/components/processes/use-process-histories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ import {
} from "@/domain/process-list";
import { HISTORY_CAPACITY, pushSample, type HistorySample } from "@/domain/sample-history";

/** Maximum number of process-detail histories retained for quick revisits. */
const RETAINED_HISTORY_LIMIT = 12;

/** How long an inactive retained history may sit in memory before pruning. */
const INACTIVE_HISTORY_TTL_MS = 10 * 60 * 1000;

/**
* 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
Expand Down Expand Up @@ -64,27 +70,116 @@ function appendTick(prior: Trails | undefined, tick: TrailSample): Trails {
};
}

/** Applies the bounded live-history policy to the key set sampled each tick. */
function pruneTrackedKeys(
previous: ReadonlySet<string>,
activeKey: string | undefined,
accessedAt: ReadonlyMap<string, number>,
now: number,
): Set<string> {
const keep = new Set<string>();
if (activeKey !== undefined) {
keep.add(activeKey);
}

const inactiveKeys = [...previous]
.filter((key) => key !== activeKey)
.filter((key) => now - (accessedAt.get(key) ?? 0) <= INACTIVE_HISTORY_TTL_MS)
.sort((left, right) => (accessedAt.get(right) ?? 0) - (accessedAt.get(left) ?? 0));

for (const key of inactiveKeys) {
if (keep.size >= RETAINED_HISTORY_LIMIT) {
break;
}
keep.add(key);
}

return keep;
}

/** Removes trails for histories that are no longer part of the live cache. */
function pruneTrailCache(
previous: Map<string, Trails>,
retainedKeys: ReadonlySet<string>,
): Map<string, Trails> {
if (previous.size === retainedKeys.size && [...previous.keys()].every((key) => retainedKeys.has(key))) {
return previous;
}

const next = new Map<string, Trails>();
for (const [key, trails] of previous) {
if (retainedKeys.has(key)) {
next.set(key, trails);
}
}
return next;
}

/** Drops side-channel cache records for histories that were evicted. */
function pruneCacheRecords(
accessRecords: Map<string, number>,
sampledRevisions: Map<string, number>,
bufferedTicks: Map<string, TrailSample[]>,
retainedKeys: ReadonlySet<string>,
): void {
for (const key of accessRecords.keys()) {
if (!retainedKeys.has(key)) {
accessRecords.delete(key);
}
}
for (const key of sampledRevisions.keys()) {
if (!retainedKeys.has(key)) {
sampledRevisions.delete(key);
}
}
for (const key of bufferedTicks.keys()) {
if (!retainedKeys.has(key)) {
bufferedTicks.delete(key);
}
}
}

/**
* Keeps short CPU/memory trails for process details the user has opened.
* Keeps short CPU/memory/member trails for a bounded set of recent details.
*
* 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.
* rather than a gap. Recently viewed details stay live in a bounded recency
* cache, so quick back-and-forth navigation shows current graphs without
* letting member breakdowns accumulate for the renderer lifetime.
*/
export function useProcessHistories(
snapshot: ProcessSnapshot,
frozen: boolean,
activeKey: string | undefined,
): (key: string, sort: SortMode) => ProcessHistory {
const [trailsByKey, setTrailsByKey] = useState<Map<string, Trails>>(() => new Map());
const trackedKeys = useRef(new Set<string>());
const lastRevision = useRef<number | null>(null);
const accessedAt = useRef(new Map<string, number>());
const sampledRevisionByKey = useRef(new Map<string, number>());
const frozenRef = useRef(frozen);
// Ticks that arrived while frozen, per key, oldest first - replayed on thaw.
const buffer = useRef(new Map<string, TrailSample[]>());

const reconcileTrackedKeys = useCallback((now: number) => {
if (activeKey !== undefined) {
accessedAt.current.set(activeKey, now);
}
trackedKeys.current = pruneTrackedKeys(trackedKeys.current, activeKey, accessedAt.current, now);
pruneCacheRecords(
accessedAt.current,
sampledRevisionByKey.current,
buffer.current,
trackedKeys.current,
);
}, [activeKey]);

const commitPrunedTrails = useCallback(() => {
setTrailsByKey((previous) => pruneTrailCache(previous, trackedKeys.current));
}, []);

useEffect(() => {
const wasFrozen = frozenRef.current;
frozenRef.current = frozen;
Expand Down Expand Up @@ -112,48 +207,57 @@ export function useProcessHistories(
}, [frozen]);

useEffect(() => {
if (lastRevision.current === snapshot.revision) {
reconcileTrackedKeys(Date.now());

if (trackedKeys.current.size === 0) {
commitPrunedTrails();
return;
}
lastRevision.current = snapshot.revision;
if (trackedKeys.current.size === 0) {

const unsampledKeys = new Set(
[...trackedKeys.current].filter((key) => sampledRevisionByKey.current.get(key) !== snapshot.revision),
);
if (unsampledKeys.size === 0) {
commitPrunedTrails();
return;
}
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 ticks = sampleTrails(snapshot, unsampledKeys);
const appendTicks = new Map<string, TrailSample>();

for (const key of unsampledKeys) {
const tick = ticks.get(key);
if (tick === undefined) {
trackedKeys.current.delete(key);
accessedAt.current.delete(key);
sampledRevisionByKey.current.delete(key);
buffer.current.delete(key);
continue;
}

sampledRevisionByKey.current.set(key, snapshot.revision);
if (frozenRef.current && key === activeKey) {
const queued = buffer.current.get(key) ?? [];
queued.push(tick);
buffer.current.set(key, queued.slice(-HISTORY_CAPACITY));
continue;
}
return;
}

// Live: drop tracked keys whose target has vanished, then append.
for (const key of trackedKeys.current) {
if (!ticks.has(key)) {
trackedKeys.current.delete(key);
}
appendTicks.set(key, tick);
}

setTrailsByKey((previous) => {
const next = new Map<string, Trails>();
for (const key of trackedKeys.current) {
const tick = ticks.get(key);
if (tick === undefined) {
continue;
}
next.set(key, appendTick(previous.get(key), tick));
const retained = pruneTrailCache(previous, trackedKeys.current);
const next = new Map(retained);
for (const [key, tick] of appendTicks) {
next.set(key, appendTick(retained.get(key), tick));
}
return next;
});
}, [snapshot]);
}, [activeKey, snapshot, reconcileTrackedKeys, commitPrunedTrails]);

return useCallback(
(key: string, sort: SortMode): ProcessHistory => {
trackedKeys.current.add(key);
const trails = trailsByKey.get(key);
return {
history: (sort === "cpu" ? trails?.cpu : trails?.memory) ?? [],
Expand Down
Loading