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
1 change: 1 addition & 0 deletions FASTBuildMonitorVSCode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 19 additions & 5 deletions FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
18 changes: 16 additions & 2 deletions FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -82,13 +85,24 @@ function App() {
<div className="main-content">
<Timeline
session={session}
coreRows={coreRows}
timeScale={timeScale}
onTimeScaleChange={setTimeScale}
/>
<SidePanel session={session} workers={snapshot.workers} />
</div>
<RecentJobs session={session} />
<div className="log-path">Log: {snapshot.logPath || '(unknown)'}</div>
<div className="monitor-footer">
<span
className="workers-summary"
title="Remote worker hosts and peak concurrent job slots per host observed during this build, not hardware core counts."
>
{workerCount} workers - {remoteCores} remote cores - {localCores} local cores
</span>
<span className="log-path" title={snapshot.logPath}>
Log: {snapshot.logPath || '(unknown)'}
</span>
</div>
</>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT License.

import { useEffect, useRef, useCallback } from 'react';
import type { CoreRow } from '../coreRows';
import {
BuildJob,
BuildSession,
Expand All @@ -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<string, CoreSlot[]>();
const displayNameByKey = new Map<string, string>();

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) {
Expand Down Expand Up @@ -196,6 +135,7 @@ function setupCanvas(

export default function Timeline({
session,
coreRows,
timeScale,
onTimeScaleChange,
}: TimelineProps) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -528,7 +466,7 @@ export default function Timeline({
canvas.title = '';
}
},
[session, timeScale]
[session, coreRows, timeScale]
);

return (
Expand Down
83 changes: 83 additions & 0 deletions FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/coreRows.ts
Original file line number Diff line number Diff line change
@@ -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<string, CoreSlot[]>();
const displayNameByKey = new Map<string, string>();

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<string>();
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 };
}
72 changes: 72 additions & 0 deletions FASTBuildMonitorVSCode/webview-ui/tests/coreRows.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
Loading
Loading