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: 1 addition & 1 deletion FASTBuildMonitorVSCode/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ webview-ui/

# Known Issues and TODO

- [ ] Improve tooltips.
- [x] Improve tooltips with styled sections and live job details.
- [ ] Better scrolling behavior to navigate to previous events.
- [ ] Ability to expand workers (watch cores).
- [ ] More detailed task information, navigate to messages when clicking tasks.
Expand Down
2 changes: 1 addition & 1 deletion FASTBuildMonitorVSCode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
## Features

- **Timeline/Gantt chart** — Canvas-rendered visualization of build jobs across workers, color-coded by status. Bar labels use black text for at least 4.5:1 contrast against every status fill in all themes, including legacy mode.
- **Job tooltips** — Hover over any job bar to see name, host, status, and duration
- **Live job tooltips** — Hover over a timeline bar or Recent Jobs row for 350 ms for a themed, sectioned view of the full file/target path, host, status, timing relative to build start, and compiler output/diagnostics when available. Timeline tooltips also identify the virtual core/slot. Press Escape to dismiss.
- **Worker panel** — Live active/total/cached job counts per worker host
- **Workers summary** — Monitor footer shows remote worker count, remote cores, and local cores. Core counts reflect peak concurrent job slots observed per host in the current build, matching the timeline, rather than hardware core counts.
- **Build statistics** — Total, active, succeeded, cached, and failed counts
Expand Down
87 changes: 87 additions & 0 deletions FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,93 @@ body.vscode-dark:not(.legacy-colors) {
left: 180px;
}

/* Portaled outside the scrollers to avoid clipping and changing scroll extents. */
.job-tooltip {
position: fixed;
z-index: 100;
width: max-content;
min-width: min(280px, calc(100vw - 16px));
max-width: min(460px, calc(100vw - 16px));
max-height: calc(100vh - 16px);
overflow: auto;
overscroll-behavior: contain;
padding: 12px;
border: 1px solid var(--vscode-editorHoverWidget-border, var(--card-border));
border-radius: 6px;
background: var(--vscode-editorHoverWidget-background, var(--card-bg));
color: var(--vscode-editorHoverWidget-foreground, var(--text));
box-shadow: 0 4px 16px var(--vscode-widget-shadow, rgba(0, 0, 0, 0.35));
font-size: 12px;
line-height: 1.5;
overflow-wrap: anywhere;
cursor: text;
}

.legacy-colors .job-tooltip {
background: var(--card-bg);
color: var(--text);
border-color: var(--card-border);
}

.job-tooltip-heading {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 6px 12px;
justify-content: space-between;
}

.job-tooltip-heading strong {
font-size: 13px;
min-width: 0;
}

.job-tooltip-status {
font-weight: 600;
}

.job-tooltip-section {
border-top: 1px solid var(--border);
margin-top: 8px;
padding-top: 6px;
}

.job-tooltip-section h4 {
color: var(--text-dim);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 4px;
}

.job-tooltip-path,
.job-tooltip pre {
font-family: var(--vscode-editor-font-family, monospace);
white-space: pre-wrap;
}

.job-tooltip dl {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 2px 20px;
}

.job-tooltip dt {
color: var(--text-dim);
}

.job-tooltip dd {
text-align: right;
font-variant-numeric: tabular-nums;
}

.job-tooltip pre {
max-height: 160px;
overflow: auto;
overscroll-behavior: contain;
font-size: 11px;
}

/* Side panel */
.side-panel {
width: 280px;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
BuildJobStatus, STATUS_TEXT_COLORS, formatDuration, getDisplayName,
type BuildJob, type BuildSession,
} from '../types';

interface JobHover {
job: BuildJob;
session: BuildSession;
x: number;
y: number;
coreIndex?: number;
}

function sameJob(a: BuildJob, b: BuildJob): boolean {
return a.startTime === b.startTime && a.hostName === b.hostName && a.eventName === b.eventName;
}

function sameTarget(a: JobHover | undefined, b: JobHover): boolean {
return a !== undefined && a.session.processId === b.session.processId &&
a.session.startTime === b.session.startTime && sameJob(a.job, b.job);
}

export function useJobTooltip(session?: BuildSession) {
const [hover, setHover] = useState<JobHover>();
const active = useRef<JobHover>();
const pending = useRef<JobHover>();
const showTimer = useRef<number>();
const hideTimer = useRef<number>();
const id = useId();
const cancelShow = useCallback(() => {
window.clearTimeout(showTimer.current);
pending.current = undefined;
}, []);
const keepOpen = useCallback(() => {
window.clearTimeout(hideTimer.current);
hideTimer.current = undefined;
cancelShow();
}, [cancelShow]);
const dismiss = useCallback(() => {
keepOpen();
active.current = undefined;
setHover(undefined);
}, [keepOpen]);
const show = useCallback((target: JobHover) => {
window.clearTimeout(hideTimer.current);
hideTimer.current = undefined;
if (sameTarget(active.current, target)) {
cancelShow();
return;
}
if (sameTarget(pending.current, target)) {
pending.current = target;
return;
}
cancelShow();
pending.current = target;
showTimer.current = window.setTimeout(() => {
active.current = pending.current;
pending.current = undefined;
setHover(active.current);
}, 350);
}, [cancelShow]);
// Allow crossing the gap to select paths and scroll long diagnostics.
const scheduleDismiss = useCallback(() => {
cancelShow();
if (hideTimer.current === undefined) {
hideTimer.current = window.setTimeout(dismiss, 300);
}
}, [dismiss, cancelShow]);

useEffect(() => {
const handleKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') dismiss();
};
window.addEventListener('keydown', handleKey);
window.addEventListener('blur', dismiss);
return () => {
keepOpen();
window.removeEventListener('keydown', handleKey);
window.removeEventListener('blur', dismiss);
};
}, [dismiss, keepOpen]);
useEffect(dismiss, [dismiss, session?.processId, session?.startTime]);

return { hover, show, dismiss, scheduleDismiss, keepOpen, id };
}

interface JobTooltipProps {
hover: JobHover;
session?: BuildSession;
id: string;
onMouseEnter: () => void;
onMouseLeave: () => void;
}

export default function JobTooltip({
hover, session, id, onMouseEnter, onMouseLeave,
}: JobTooltipProps) {
const ref = useRef<HTMLDivElement>(null);
const [, tick] = useState(0);
const [position, setPosition] = useState({ left: 0, top: 0 });
// Snapshots replace objects. Resolve the current event once per snapshot,
// not on every clock tick, so status/output stay live without scanning jobs.
const job = useMemo(() =>
session?.processId === hover.session.processId && session.startTime === hover.session.startTime
? session.jobs.find(candidate => sameJob(candidate, hover.job))
: undefined,
[session, hover.session, hover.job]);
const running = job?.status === BuildJobStatus.Building &&
job.endTime === undefined && session?.endTime === undefined;

useEffect(() => {
if (!running) return;
const timer = window.setInterval(() => tick(value => value + 1), 100);
return () => window.clearInterval(timer);
}, [running]);

const hasJob = job !== undefined;
useLayoutEffect(() => {
let anchor: { left: number; top: number } | undefined;
const place = () => {
const rect = ref.current?.getBoundingClientRect();
if (!rect) return;
const gap = 14;
const margin = 8;
// Keep the opening position across live updates; only move enough
// to stay in the viewport if the card or viewport changes size.
anchor ??= {
left: hover.x + gap + rect.width <= window.innerWidth - margin
? hover.x + gap : hover.x - gap - rect.width,
top: hover.y + gap + rect.height <= window.innerHeight - margin
? hover.y + gap : hover.y - gap - rect.height,
};
anchor = {
left: Math.max(margin, Math.min(anchor.left, window.innerWidth - rect.width - margin)),
top: Math.max(margin, Math.min(anchor.top, window.innerHeight - rect.height - margin)),
};
setPosition(anchor);
};
place();
const observer = new ResizeObserver(place);
if (ref.current) observer.observe(ref.current);
window.addEventListener('resize', place);
return () => {
observer.disconnect();
window.removeEventListener('resize', place);
};
}, [hover, hasJob]);

if (!job || !session) return;
const end = job.endTime ?? session.endTime ?? Date.now();
return createPortal(
<div
ref={ref} id={id} className="job-tooltip" role="tooltip" style={position}
onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}
onWheel={event => event.stopPropagation()}
>
<div className="job-tooltip-heading">
<strong>{getDisplayName(job.eventName)}</strong>
<span className="job-tooltip-status" style={{ color: STATUS_TEXT_COLORS[job.status] }}>
{job.status}
</span>
</div>
<section className="job-tooltip-section">
<h4>File / Target</h4>
<div className="job-tooltip-path">{job.eventName}</div>
</section>
<section className="job-tooltip-section">
<h4>Execution</h4>
<dl>
<dt>Host</dt><dd>{job.hostName}</dd>
{hover.coreIndex !== undefined && <><dt>Core / Slot</dt><dd>#{hover.coreIndex}</dd></>}
</dl>
</section>
<section className="job-tooltip-section">
<h4>Timing</h4>
<dl>
<dt>{running ? 'Time Elapsed' : 'Duration'}</dt>
<dd>{formatDuration(Math.max(0, end - job.startTime))}</dd>
<dt>Start Time (build)</dt>
<dd>+{formatDuration(job.startTime - session.startTime)}</dd>
<dt>End Time (build)</dt>
<dd>{job.endTime !== undefined
? `+${formatDuration(job.endTime - session.startTime)}`
: running ? 'In progress' : 'Not reported'}</dd>
</dl>
</section>
{job.message && (
<section className="job-tooltip-section">
<h4>Output / Diagnostics</h4>
<pre>{job.message}</pre>
</section>
)}
</div>,
document.body,
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { useId, useState } from 'react';
import { BuildJob, BuildSession, STATUS_TEXT_COLORS, getDisplayName, formatDuration } from '../types';
import JobTooltip, { useJobTooltip } from './JobTooltip';

interface RecentJobsProps {
session?: BuildSession;
Expand Down Expand Up @@ -57,6 +58,7 @@ function nextSort(current: SortState | undefined, key: SortKey): SortState | und
export default function RecentJobs({ session }: RecentJobsProps) {
const [expanded, setExpanded] = useState(true);
const [sort, setSort] = useState<SortState>();
const { hover, show, dismiss, scheduleDismiss, keepOpen, id } = useJobTooltip(session);
const listId = useId();

const now = Date.now();
Expand All @@ -77,7 +79,7 @@ export default function RecentJobs({ session }: RecentJobsProps) {
aria-controls={listId}
aria-label={expanded ? 'Collapse Recent Jobs' : 'Expand Recent Jobs'}
title={expanded ? 'Collapse Recent Jobs' : 'Expand Recent Jobs'}
onClick={() => setExpanded(value => !value)}
onClick={() => { dismiss(); setExpanded(value => !value); }}
>
{expanded ? '\u25BE' : '\u25B8'}
</button>
Expand All @@ -89,6 +91,7 @@ export default function RecentJobs({ session }: RecentJobsProps) {
role="table"
aria-label="Recent Jobs"
hidden={!expanded}
onScroll={dismiss}
>
<div className="job-header-group" role="rowgroup">
<div className="job-header-row" role="row">
Expand All @@ -105,7 +108,7 @@ export default function RecentJobs({ session }: RecentJobsProps) {
type="button"
className="job-sort-button"
title={`Sort by ${column.label}`}
onClick={() => setSort(current => nextSort(current, column.key))}
onClick={() => { dismiss(); setSort(current => nextSort(current, column.key)); }}
>
{column.label}
<span className="job-sort-indicator" aria-hidden="true">
Expand All @@ -122,7 +125,13 @@ export default function RecentJobs({ session }: RecentJobsProps) {
const color = STATUS_TEXT_COLORS[job.status];
const dur = formatDuration(getDuration(job, now));
return (
<div className="job-row" role="row" key={`${job.eventName}-${job.startTime}-${index}`}>
<div
className="job-row" role="row" key={`${job.eventName}-${job.startTime}-${index}`}
onMouseMove={event => {
if (session) show({ job, session, x: event.clientX, y: event.clientY });
}}
onMouseLeave={scheduleDismiss}
>
<div className="job-name" role="cell">{getDisplayName(job.eventName)}</div>
<div className="job-host" role="cell">{job.hostName}</div>
<div className="job-status" role="cell" style={{ color }}>
Expand All @@ -134,6 +143,10 @@ export default function RecentJobs({ session }: RecentJobsProps) {
})}
</div>
</div>
{hover && (
<JobTooltip hover={hover} session={session} id={id}
onMouseEnter={keepOpen} onMouseLeave={scheduleDismiss} />
)}
</div>
);
}
Loading
Loading