diff --git a/FASTBuildMonitorVSCode/README.md b/FASTBuildMonitorVSCode/README.md
index 292aac2..65cf989 100644
--- a/FASTBuildMonitorVSCode/README.md
+++ b/FASTBuildMonitorVSCode/README.md
@@ -17,6 +17,7 @@
- **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
- **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
- **Progress bar** — Overall build progress percentage with automatically computed black/white text colors for the filled and unfilled portions. The label follows the animated fill boundary and theme changes without an added background.
- **Recent jobs list** — Scrollable feed of the latest jobs with status and timing
diff --git a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.css b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.css
index f0892e6..011e9fb 100644
--- a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.css
+++ b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.css
@@ -455,15 +455,29 @@ body.vscode-dark:not(.legacy-colors) {
text-align: right;
}
-/* Log path info */
+/* Worker summary and log path */
+.monitor-footer {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 4px 12px;
+ padding: 4px 12px;
+ background: var(--toolbar-bg);
+ border-top: 1px solid var(--border);
+ flex-shrink: 0;
+}
+
+.workers-summary {
+ font-size: 12px;
+ color: var(--text);
+}
+
.log-path {
+ flex: 1 1 200px;
+ min-width: 0;
font-size: 10px;
color: var(--text-dim);
- padding: 2px 12px;
- background: var(--toolbar-bg);
- border-top: 1px solid var(--border);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
- flex-shrink: 0;
}
diff --git a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.tsx b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.tsx
index 0300469..85a3281 100644
--- a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.tsx
+++ b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.tsx
@@ -1,8 +1,9 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
import { MonitorSnapshot } from './types';
+import { getCoreRows, getWorkerSummary } from './coreRows';
import Toolbar, { getStatusText, formatElapsedCompact } from './components/Toolbar';
import ProgressBar from './components/ProgressBar';
import Timeline from './components/Timeline';
@@ -55,6 +56,8 @@ function App() {
}, []);
const session = snapshot.session;
+ const coreRows = useMemo(() => getCoreRows(session), [session]);
+ const { workerCount, remoteCores, localCores } = getWorkerSummary(coreRows);
const progress = session ? session.progress : 0;
const elapsed = session
? formatElapsedCompact((session.endTime || Date.now()) - session.startTime)
@@ -82,13 +85,24 @@ function App() {
- Log: {snapshot.logPath || '(unknown)'}
+
+
+ {workerCount} workers - {remoteCores} remote cores - {localCores} local cores
+
+
+ Log: {snapshot.logPath || '(unknown)'}
+
+
>
);
}
diff --git a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/components/Timeline.tsx b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/components/Timeline.tsx
index 1c1ded3..74fb1da 100644
--- a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/components/Timeline.tsx
+++ b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/components/Timeline.tsx
@@ -2,6 +2,7 @@
// Licensed under the MIT License.
import { useEffect, useRef, useCallback } from 'react';
+import type { CoreRow } from '../coreRows';
import {
BuildJob,
BuildSession,
@@ -21,73 +22,11 @@ const RIGHT_PAD = 100;
interface TimelineProps {
session?: BuildSession;
+ coreRows: CoreRow[];
timeScale: number;
onTimeScaleChange: (scale: number) => void;
}
-interface CoreRow {
- hostName: string;
- coreIndex: number;
- jobs: BuildJob[];
-}
-
-// Assign jobs to virtual "cores" per host via first-fit scheduling, so that
-// each row in the timeline represents a single execution slot that runs at
-// most one job at a time. The number of cores per host therefore equals the
-// peak number of concurrent jobs that ran on that host.
-function getCoreRows(session: BuildSession): CoreRow[] {
- const sortedJobs = [...session.jobs].sort((a, b) => a.startTime - b.startTime);
-
- interface CoreSlot { jobs: BuildJob[]; lastEndTime: number; }
- const hostCores = new Map();
- const displayNameByKey = new Map();
-
- for (const job of sortedJobs) {
- const key = job.hostName.toLowerCase();
- if (!displayNameByKey.has(key)) {
- displayNameByKey.set(key, job.hostName);
- }
- let cores = hostCores.get(key);
- if (!cores) {
- cores = [];
- hostCores.set(key, cores);
- }
-
- const jobEnd = job.endTime ?? Date.now();
- let assigned = false;
- for (const core of cores) {
- if (core.lastEndTime <= job.startTime) {
- core.jobs.push(job);
- core.lastEndTime = jobEnd;
- assigned = true;
- break;
- }
- }
- if (!assigned) {
- cores.push({ jobs: [job], lastEndTime: jobEnd });
- }
- }
-
- // Local host first, then remaining hosts alphabetically.
- const hostKeys = Array.from(hostCores.keys()).sort((a, b) => {
- const aLocal = a === 'local';
- const bLocal = b === 'local';
- if (aLocal && !bLocal) return -1;
- if (!aLocal && bLocal) return 1;
- return a.localeCompare(b);
- });
-
- const rows: CoreRow[] = [];
- for (const key of hostKeys) {
- const cores = hostCores.get(key)!;
- const displayName = displayNameByKey.get(key) || key;
- cores.forEach((core, idx) => {
- rows.push({ hostName: displayName, coreIndex: idx, jobs: core.jobs });
- });
- }
- return rows;
-}
-
function getMaxTime(session: BuildSession): number {
let max = Date.now();
if (session.endTime) {
@@ -196,6 +135,7 @@ function setupCanvas(
export default function Timeline({
session,
+ coreRows,
timeScale,
onTimeScaleChange,
}: TimelineProps) {
@@ -241,7 +181,6 @@ export default function Timeline({
const dpr = window.devicePixelRatio || 1;
const containerWidth = container.clientWidth;
const viewportContentWidth = Math.max(1, containerWidth - LABEL_WIDTH);
- const coreRows = session ? getCoreRows(session) : [];
const bodyHeight = Math.max(MIN_BODY_HEIGHT, coreRows.length * ROW_HEIGHT + PAD * 2);
// Resolve theme-aware colors from CSS custom properties so the canvas
@@ -434,7 +373,7 @@ export default function Timeline({
labelsCtx.moveTo(LABEL_WIDTH - 0.5, 0);
labelsCtx.lineTo(LABEL_WIDTH - 0.5, bodyHeight);
labelsCtx.stroke();
- }, [session, timeScale]);
+ }, [session, coreRows, timeScale]);
useEffect(() => {
draw();
@@ -497,7 +436,6 @@ export default function Timeline({
// are drawn in).
const mx = e.clientX - rect.left + container.scrollLeft;
const my = e.clientY - rect.top;
- const coreRows = getCoreRows(session);
const startTime = session.startTime;
let tooltipJob: BuildJob | undefined = undefined;
@@ -528,7 +466,7 @@ export default function Timeline({
canvas.title = '';
}
},
- [session, timeScale]
+ [session, coreRows, timeScale]
);
return (
diff --git a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/coreRows.ts b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/coreRows.ts
new file mode 100644
index 0000000..7a947dd
--- /dev/null
+++ b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/coreRows.ts
@@ -0,0 +1,83 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import type { BuildJob, BuildSession } from './types';
+
+export interface CoreRow {
+ hostName: string;
+ coreIndex: number;
+ jobs: BuildJob[];
+}
+
+// Assign jobs to virtual "cores" per host via first-fit scheduling, so that
+// each row represents a single execution slot. Core counts are the peak
+// concurrency observed in the log, not the host's hardware core count.
+export function getCoreRows(session?: BuildSession): CoreRow[] {
+ if (!session) return [];
+ const sortedJobs = [...session.jobs].sort((a, b) => a.startTime - b.startTime);
+
+ interface CoreSlot { jobs: BuildJob[]; lastEndTime: number; }
+ const hostCores = new Map();
+ const displayNameByKey = new Map();
+
+ for (const job of sortedJobs) {
+ const key = job.hostName.toLowerCase();
+ if (!displayNameByKey.has(key)) {
+ displayNameByKey.set(key, job.hostName);
+ }
+ let cores = hostCores.get(key);
+ if (!cores) {
+ cores = [];
+ hostCores.set(key, cores);
+ }
+
+ const jobEnd = job.endTime ?? Date.now();
+ let assigned = false;
+ for (const core of cores) {
+ if (core.lastEndTime <= job.startTime) {
+ core.jobs.push(job);
+ core.lastEndTime = jobEnd;
+ assigned = true;
+ break;
+ }
+ }
+ if (!assigned) {
+ cores.push({ jobs: [job], lastEndTime: jobEnd });
+ }
+ }
+
+ // Local host first, then remaining hosts alphabetically.
+ const hostKeys = Array.from(hostCores.keys()).sort((a, b) => {
+ const aLocal = a === 'local';
+ const bLocal = b === 'local';
+ if (aLocal && !bLocal) return -1;
+ if (!aLocal && bLocal) return 1;
+ return a.localeCompare(b);
+ });
+
+ const rows: CoreRow[] = [];
+ for (const key of hostKeys) {
+ const cores = hostCores.get(key)!;
+ const displayName = displayNameByKey.get(key) || key;
+ cores.forEach((core, idx) => {
+ rows.push({ hostName: displayName, coreIndex: idx, jobs: core.jobs });
+ });
+ }
+ return rows;
+}
+
+export function getWorkerSummary(rows: CoreRow[]) {
+ const workers = new Set();
+ let remoteCores = 0;
+ let localCores = 0;
+ for (const row of rows) {
+ const host = row.hostName.toLowerCase();
+ if (host === 'local') {
+ localCores++;
+ } else {
+ workers.add(host);
+ remoteCores++;
+ }
+ }
+ return { workerCount: workers.size, remoteCores, localCores };
+}
diff --git a/FASTBuildMonitorVSCode/webview-ui/tests/coreRows.test.ts b/FASTBuildMonitorVSCode/webview-ui/tests/coreRows.test.ts
new file mode 100644
index 0000000..d969f42
--- /dev/null
+++ b/FASTBuildMonitorVSCode/webview-ui/tests/coreRows.test.ts
@@ -0,0 +1,72 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { expect, test } from 'vitest';
+import { getCoreRows, getWorkerSummary } from '../src/monitor_panel/coreRows';
+import { BuildJobStatus } from '../src/monitor_panel/types';
+import type { BuildJob, BuildSession } from '../src/monitor_panel/types';
+
+function job(hostName: string, startTime: number, endTime?: number): BuildJob {
+ return {
+ hostName, startTime, endTime, eventName: `${hostName}-${startTime}`,
+ status: endTime === undefined ? BuildJobStatus.Building : BuildJobStatus.Success,
+ };
+}
+
+function session(jobs: BuildJob[], endTime?: number): BuildSession {
+ return { processId: 1, logVersion: 1, startTime: 0, progress: 0, jobs, endTime };
+}
+
+test('no session or an empty build has zero workers and cores', () => {
+ const expected = { workerCount: 0, remoteCores: 0, localCores: 0 };
+ expect(getWorkerSummary(getCoreRows())).toStrictEqual(expected);
+ expect(getWorkerSummary(getCoreRows(session([])))).toStrictEqual(expected);
+});
+
+test('local jobs do not count as remote workers and adjacent jobs reuse a core', () => {
+ const rows = getCoreRows(session([
+ job('LOCAL', 10, 20), job('local', 0, 10), job('Local', 5, 15),
+ ]));
+ expect(getWorkerSummary(rows)).toStrictEqual({ workerCount: 0, remoteCores: 0, localCores: 2 });
+ expect(rows[0].jobs.map(j => j.startTime)).toStrictEqual([0, 10]);
+});
+
+test('remote core counts sum per-host peaks, not simultaneous global activity', () => {
+ const build = session([
+ job('worker-b', 20, 30), job('worker-a', 0, 10),
+ job('WORKER-A', 5, 15), job('worker-b', 21, 31), job('worker-b', 22, 32),
+ ], 32);
+ const originalOrder = [...build.jobs];
+ const rows = getCoreRows(build);
+ expect(getWorkerSummary(rows)).toStrictEqual({ workerCount: 2, remoteCores: 5, localCores: 0 });
+ expect(build.jobs).toStrictEqual(originalOrder);
+ expect(rows.map(r => r.coreIndex)).toStrictEqual([0, 1, 0, 1, 2]);
+ expect(rows.map(r => r.hostName)).toStrictEqual([
+ 'worker-a', 'worker-a', 'worker-b', 'worker-b', 'worker-b',
+ ]);
+});
+
+test('active jobs occupy separate cores and completed builds retain peak counts', () => {
+ const build = session([job('local', 0), job('worker', 1), job('WORKER', 2)]);
+ const expected = { workerCount: 1, remoteCores: 2, localCores: 1 };
+ expect(getWorkerSummary(getCoreRows(build))).toStrictEqual(expected);
+ for (const j of build.jobs) {
+ j.endTime = 10;
+ j.status = BuildJobStatus.Stopped;
+ }
+ build.endTime = 10;
+ expect(getWorkerSummary(getCoreRows(build))).toStrictEqual(expected);
+ expect(getWorkerSummary(getCoreRows(session([job('local', 20, 30)])))).toStrictEqual(
+ { workerCount: 0, remoteCores: 0, localCores: 1 });
+});
+
+test('matches the standalone example: 10 workers, 30 remote cores, 64 local cores', () => {
+ const jobs = Array.from({ length: 64 }, () => job('local', 0, 100));
+ for (let i = 0; i < 10; i++) {
+ jobs.push(...Array.from({ length: 3 }, () => job(`worker-${i}`, 0, 100)));
+ }
+ const rows = getCoreRows(session(jobs, 100));
+ expect(getWorkerSummary(rows)).toStrictEqual({ workerCount: 10, remoteCores: 30, localCores: 64 });
+ expect(rows.length).toBe(94);
+ expect(rows[0].hostName).toBe('local');
+});
diff --git a/FASTBuildMonitorVSCode/webview-ui/tests/timeline.test.tsx b/FASTBuildMonitorVSCode/webview-ui/tests/timeline.test.tsx
index bee1c51..474a810 100644
--- a/FASTBuildMonitorVSCode/webview-ui/tests/timeline.test.tsx
+++ b/FASTBuildMonitorVSCode/webview-ui/tests/timeline.test.tsx
@@ -4,6 +4,7 @@
import { act, fireEvent, render } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import Timeline from '../src/monitor_panel/components/Timeline';
+import { getCoreRows } from '../src/monitor_panel/coreRows';
import { BuildJobStatus } from '../src/monitor_panel/types';
import { canvasContext, getContext, ResizeObserverMock } from './setup';
import { job, session } from './fixtures';
@@ -22,7 +23,7 @@ function canvas(container: HTMLElement, selector: string) {
describe('timeline rendering and interaction', () => {
it('renders an empty state and releases resize observers on unmount', () => {
- const { container, unmount } = render();
+ const { container, unmount } = render();
const body = canvas(container, '.timeline-body canvas');
expect(canvasContext(body).fillText).toHaveBeenCalledWith('Waiting for FASTBuild data...', 410, 84);
const observer = ResizeObserverMock.instances[0];
@@ -45,7 +46,8 @@ describe('timeline rendering and interaction', () => {
job({ hostName: 'worker-b', startTime: 4000, endTime: 6000 }),
];
const original = [...jobs];
- const { container } = render();
+ const build = session({ jobs });
+ const { container } = render();
const labels = canvasContext(canvas(container, '.timeline-labels canvas'));
expect(labels.fillText.mock.calls.map(([text]) => text)).toEqual([
'LOCAL (Core # 0)', 'LOCAL (Core # 1)', 'worker-a (Core # 0)',
@@ -60,22 +62,25 @@ describe('timeline rendering and interaction', () => {
job({ endTime: undefined, status: BuildJobStatus.Building }),
job({ startTime: 2000, endTime: undefined, status: BuildJobStatus.Building }),
];
+ const build = session({ jobs, endTime: undefined });
const { container, rerender } = render(
- ,
+ ,
);
let labels = canvasContext(canvas(container, '.timeline-labels canvas'));
expect(labels.fillText).toHaveBeenCalledTimes(2);
labels.fillText.mockClear();
- rerender( ({ ...j, endTime: 11000, status: BuildJobStatus.Stopped })) })}
+ const completedBuild = session({ jobs: jobs.map(j => ({ ...j, endTime: 11000, status: BuildJobStatus.Stopped })) });
+ rerender();
labels = canvasContext(canvas(container, '.timeline-labels canvas'));
expect(labels.fillText).toHaveBeenCalledTimes(2);
});
it('shows and clears tooltips using the hovered row, filename, host, status and duration', () => {
- const { container } = render();
+ ] });
+ const { container } = render();
const body = canvas(container, '.timeline-body canvas');
fireEvent.mouseMove(body, { clientX: 10, clientY: 10 });
expect(body.title).toBe('file.cpp (local) - Success - 5.0s');
@@ -88,9 +93,10 @@ describe('timeline rendering and interaction', () => {
});
it('converts viewport hover coordinates into scrolled content coordinates', () => {
- const { container } = render();
+ ], endTime: 21000 });
+ const { container } = render();
const scroller = container.querySelector('.timeline-container')!;
scroller.scrollLeft = 500;
const body = canvas(container, '.timeline-body canvas');
@@ -100,7 +106,7 @@ describe('timeline rendering and interaction', () => {
it('zooms only with Ctrl+wheel and clamps scales to 1 through 200', () => {
const change = vi.fn();
- const { container, rerender } = render();
+ const { container, rerender } = render();
const scroller = container.querySelector('.timeline-container')!;
fireEvent.wheel(scroller, { deltaY: -1 });
expect(change).not.toHaveBeenCalled();
@@ -108,23 +114,25 @@ describe('timeline rendering and interaction', () => {
expect(change).toHaveBeenLastCalledWith(23);
fireEvent.wheel(scroller, { deltaY: 1, ctrlKey: true });
expect(change).toHaveBeenLastCalledWith(20 / 1.15);
- rerender();
+ rerender();
fireEvent.wheel(scroller, { deltaY: -1, ctrlKey: true });
expect(change).toHaveBeenLastCalledWith(200);
- rerender();
+ rerender();
fireEvent.wheel(scroller, { deltaY: 1, ctrlKey: true });
expect(change).toHaveBeenLastCalledWith(1);
});
it.each([1, 7, 15, 30, 50])('renders time-axis ticks at scale %i', timeScale => {
- const { container } = render();
+ const build = session();
+ const { container } = render();
const header = canvasContext(canvas(container, '.timeline-header canvas'));
expect(header.fillText).toHaveBeenCalledWith('0:00', 0, 16);
});
it('keeps large, zoomed timelines viewport-sized and formats hour labels', () => {
vi.stubGlobal('devicePixelRatio', 2);
- const { container } = render();
+ const build = session({ endTime: 7201000 });
+ const { container } = render();
const body = canvas(container, '.timeline-body canvas');
const header = canvas(container, '.timeline-header canvas');
expect(body.width).toBe(1640);
@@ -138,7 +146,7 @@ describe('timeline rendering and interaction', () => {
it('stops auto-scroll when the user scrolls back and resumes near the right edge', () => {
vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(10000);
const build = session({ endTime: 101000 });
- const { container, rerender } = render();
+ const { container, rerender } = render();
const scroller = container.querySelector('.timeline-container')!;
expect(scroller.scrollLeft).toBe(1280);
const body = canvasContext(canvas(container, '.timeline-body canvas'));
@@ -147,7 +155,8 @@ describe('timeline rendering and interaction', () => {
expect(body.translate).toHaveBeenCalledTimes(calls);
scroller.scrollLeft = 50;
fireEvent.scroll(scroller);
- rerender();
+ const updatedBuild = { ...build, endTime: 201000 };
+ rerender();
expect(scroller.scrollLeft).toBe(50);
scroller.scrollLeft = 9000;
fireEvent.scroll(scroller);
@@ -155,9 +164,10 @@ describe('timeline rendering and interaction', () => {
});
it('handles sub-second jobs and job end times beyond the reported session end', () => {
- const { container } = render();
+ ] });
+ const { container } = render();
const body = canvasContext(canvas(container, '.timeline-body canvas'));
expect(body.fill).toHaveBeenCalledTimes(2);
expect(body.fillText).toHaveBeenCalledWith('file.cpp', 3.5, 42);
@@ -166,7 +176,7 @@ describe('timeline rendering and interaction', () => {
it('does not draw when the browser cannot provide a canvas context', () => {
getContext.mockReturnValueOnce(null);
- const { container } = render();
+ const { container } = render();
expect(canvasContext(canvas(container, '.timeline-body canvas')).fillText).not.toHaveBeenCalled();
});
});