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/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
- **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
- **Recent jobs list** — Scrollable feed of the latest jobs with status and timing.
- **Theme-aware text colors** — Readable status, statistics, and elapsed-time colors for light and dark themes; high-contrast themes use the theme foreground. Chart colors stay unchanged, and `fbuildMonitor.useLegacyColors` retains the original palette.
- **Status bar integration** — See build progress/status at a glance in the VS Code status bar
- **Zoom & scroll** — Ctrl+wheel zoom, plus zoom in/out/reset buttons
Expand Down
75 changes: 73 additions & 2 deletions FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -424,18 +424,57 @@ body.vscode-dark:not(.legacy-colors) {
flex-shrink: 0;
}

/* Collapsed: keep only the header so the timeline can use the freed space. */
.recent-jobs.collapsed {
height: auto;
}

.recent-jobs-header {
display: flex;
align-items: center;
gap: 4px;
padding: 0 12px 0 4px;
background: var(--toolbar-bg);
}

.recent-jobs h3 {
font-size: 13px;
padding: 6px 12px;
background: var(--toolbar-bg);
padding: 6px 0;
color: var(--text);
}

.recent-jobs-toggle {
width: 20px;
height: 20px;
border: none;
border-radius: 3px;
background: transparent;
color: var(--text);
font-size: 13px;
line-height: 20px;
cursor: pointer;
}

.recent-jobs-toggle:hover {
background: var(--control-bg);
}

.recent-jobs-list {
flex: 1;
overflow-y: auto;
}

/* Stays visible while scrolling and shares the row grid so columns align
exactly (it lives inside the scroll area, so the scrollbar affects both). */
.job-header-group {
position: sticky;
top: 0;
z-index: 1;
background: var(--bg-alt);
border-bottom: 1px solid var(--border);
}

.job-header-row,
.job-row {
display: grid;
grid-template-columns: 1fr 140px 80px 70px;
Expand All @@ -445,6 +484,38 @@ body.vscode-dark:not(.legacy-colors) {
align-items: center;
}

.job-sort-button {
display: inline-flex;
align-items: center;
gap: 3px;
max-width: 100%;
padding: 0;
border: none;
background: none;
color: var(--text-dim);
font: inherit;
font-weight: bold;
white-space: nowrap;
cursor: pointer;
}

.job-sort-button:hover {
color: var(--text-bright);
}

.job-header[aria-sort='ascending'] .job-sort-button,
.job-header[aria-sort='descending'] .job-sort-button {
color: var(--text);
}

.job-sort-indicator {
font-size: 8px;
}

.job-header-duration {
text-align: right;
}

.job-row:nth-child(even) {
background: var(--even-row-bg);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,33 +1,138 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

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

interface RecentJobsProps {
session?: BuildSession;
}

type SortKey = 'name' | 'host' | 'status' | 'duration';
type SortDirection = 'ascending' | 'descending';

interface SortState {
key: SortKey;
direction: SortDirection;
}

interface Column {
key: SortKey;
label: string;
}

const COLUMNS: Column[] = [
{ key: 'name', label: 'File' },
{ key: 'host', label: 'Host' },
{ key: 'status', label: 'Status' },
{ key: 'duration', label: 'Duration' },
];

const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });

function getDuration(job: BuildJob, now: number): number {
return (job.endTime || now) - job.startTime;
}

function compareJobs(a: BuildJob, b: BuildJob, key: SortKey, now: number): number {
switch (key) {
case 'name':
return collator.compare(getDisplayName(a.eventName), getDisplayName(b.eventName));
case 'host':
return collator.compare(a.hostName, b.hostName);
case 'status':
return collator.compare(a.status, b.status);
case 'duration':
return getDuration(a, now) - getDuration(b, now);
}
}

// Each click on the same column cycles ascending -> descending -> unsorted
// (newest first); clicking a different column starts again at ascending.
function nextSort(current: SortState | undefined, key: SortKey): SortState | undefined {
if (current?.key !== key) return { key, direction: 'ascending' };
return current.direction === 'ascending' ? { key, direction: 'descending' } : undefined;
}

export default function RecentJobs({ session }: RecentJobsProps) {
const [expanded, setExpanded] = useState(true);
const [sort, setSort] = useState<SortState>();
const listId = useId();

const now = Date.now();
const recent = session ? session.jobs.slice(-100).reverse() : [];
if (sort) {
const sign = sort.direction === 'ascending' ? 1 : -1;
// Array.prototype.sort is stable, so ties keep newest-first order.
recent.sort((a, b) => sign * compareJobs(a, b, sort.key, now));
}

return (
<div className="recent-jobs">
<h3>Recent Jobs</h3>
<div className="recent-jobs-list">
{recent.map((job, index) => {
const color = STATUS_TEXT_COLORS[job.status];
const dur = formatDuration((job.endTime || Date.now()) - job.startTime);
return (
<div className="job-row" key={`${job.eventName}-${job.startTime}-${index}`}>
<div className="job-name">{getDisplayName(job.eventName)}</div>
<div className="job-host">{job.hostName}</div>
<div className="job-status" style={{ color }}>
{job.status}
<div className={`recent-jobs${expanded ? '' : ' collapsed'}`}>
<div className="recent-jobs-header">
<button
type="button"
className="recent-jobs-toggle"
aria-expanded={expanded}
aria-controls={listId}
aria-label={expanded ? 'Collapse Recent Jobs' : 'Expand Recent Jobs'}
title={expanded ? 'Collapse Recent Jobs' : 'Expand Recent Jobs'}
onClick={() => setExpanded(value => !value)}
>
{expanded ? '\u25BE' : '\u25B8'}
</button>
<h3>Recent Jobs</h3>
</div>
<div
id={listId}
className="recent-jobs-list"
role="table"
aria-label="Recent Jobs"
hidden={!expanded}
>
<div className="job-header-group" role="rowgroup">
<div className="job-header-row" role="row">
{COLUMNS.map(column => {
const direction = sort?.key === column.key ? sort.direction : undefined;
return (
<div
key={column.key}
className={`job-header job-header-${column.key}`}
role="columnheader"
aria-sort={direction ?? 'none'}
>
<button
type="button"
className="job-sort-button"
title={`Sort by ${column.label}`}
onClick={() => setSort(current => nextSort(current, column.key))}
>
{column.label}
<span className="job-sort-indicator" aria-hidden="true">
{direction === 'ascending' ? '\u25B2' : direction === 'descending' ? '\u25BC' : ''}
</span>
</button>
</div>
);
})}
</div>
</div>
<div role="rowgroup">
{recent.map((job, index) => {
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-name" role="cell">{getDisplayName(job.eventName)}</div>
<div className="job-host" role="cell">{job.hostName}</div>
<div className="job-status" role="cell" style={{ color }}>
{job.status}
</div>
<div className="job-duration" role="cell">{dur}</div>
</div>
<div className="job-duration">{dur}</div>
</div>
);
})}
);
})}
</div>
</div>
</div>
);
Expand Down
68 changes: 68 additions & 0 deletions FASTBuildMonitorVSCode/webview-ui/tests/components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,72 @@ describe('recent jobs', () => {
rerender(<RecentJobs session={build} />);
expect(screen.getByText('6.5s')).toBeInTheDocument();
});

it('collapses and expands the list from the header arrow', () => {
render(<RecentJobs session={session()} />);
const toggle = screen.getByRole('button', { name: 'Collapse Recent Jobs' });
const table = screen.getByRole('table', { name: 'Recent Jobs' });
expect(toggle).toHaveAttribute('aria-expanded', 'true');
expect(toggle).toHaveAttribute('aria-controls', table.id);
fireEvent.click(toggle);
expect(toggle).toHaveAttribute('aria-expanded', 'false');
expect(toggle).toHaveAccessibleName('Expand Recent Jobs');
expect(screen.queryByRole('table')).not.toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Recent Jobs' })).toBeInTheDocument();
fireEvent.click(toggle);
expect(screen.getByRole('table', { name: 'Recent Jobs' })).toBeVisible();
});

function names(container: HTMLElement) {
return [...container.querySelectorAll('.job-row .job-name')].map(el => el.textContent);
}

it('cycles each column through ascending, descending and newest-first order', () => {
vi.useFakeTimers();
vi.setSystemTime(20000);
const jobs = [
job({ eventName: 'src/b10.cpp', hostName: 'worker-2', status: BuildJobStatus.Success, startTime: 1000, endTime: 4000 }),
job({ eventName: 'src/a.cpp', hostName: 'Worker-10', status: BuildJobStatus.Failed, startTime: 2000, endTime: 3000 }),
job({ eventName: 'src/b9.cpp', hostName: 'local', status: BuildJobStatus.Building, startTime: 3000, endTime: undefined }),
];
const { container } = render(<RecentJobs session={session({ jobs })} />);
const header = (label: string) => screen.getByRole('columnheader', { name: new RegExp(label) });
const newest = ['b9.cpp', 'a.cpp', 'b10.cpp'];
expect(names(container)).toEqual(newest);
expect(screen.getAllByRole('columnheader').map(h => h.textContent))
.toEqual(['File', 'Host', 'Status', 'Duration']);

fireEvent.click(screen.getByTitle('Sort by File'));
expect(header('File')).toHaveAttribute('aria-sort', 'ascending');
expect(names(container)).toEqual(['a.cpp', 'b9.cpp', 'b10.cpp']);
fireEvent.click(screen.getByTitle('Sort by File'));
expect(header('File')).toHaveAttribute('aria-sort', 'descending');
expect(names(container)).toEqual(['b10.cpp', 'b9.cpp', 'a.cpp']);
fireEvent.click(screen.getByTitle('Sort by File'));
expect(header('File')).toHaveAttribute('aria-sort', 'none');
expect(names(container)).toEqual(newest);

fireEvent.click(screen.getByTitle('Sort by Host'));
expect(names(container)).toEqual(['b9.cpp', 'b10.cpp', 'a.cpp']);
fireEvent.click(screen.getByTitle('Sort by Status'));
expect(header('Host')).toHaveAttribute('aria-sort', 'none');
expect(header('Status')).toHaveAttribute('aria-sort', 'ascending');
expect(names(container)).toEqual(['b9.cpp', 'a.cpp', 'b10.cpp']);
fireEvent.click(screen.getByTitle('Sort by Duration'));
expect(names(container)).toEqual(['a.cpp', 'b10.cpp', 'b9.cpp']);
fireEvent.click(screen.getByTitle('Sort by Duration'));
expect(names(container)).toEqual(['b9.cpp', 'b10.cpp', 'a.cpp']);
expect(jobs.map(j => j.eventName)).toEqual(['src/b10.cpp', 'src/a.cpp', 'src/b9.cpp']);
});

it('keeps newest-first order for ties and sorts only the 100 most recent jobs', () => {
const jobs = Array.from({ length: 101 }, (_, i) => job({
eventName: `src/job-${i}.cpp`, hostName: i === 0 ? 'aaa' : 'same', startTime: 1000 + i,
}));
const { container } = render(<RecentJobs session={session({ jobs })} />);
fireEvent.click(screen.getByTitle('Sort by Host'));
expect(names(container)).toHaveLength(100);
expect(names(container).slice(0, 2)).toEqual(['job-100.cpp', 'job-99.cpp']);
expect(screen.queryByText('aaa')).not.toBeInTheDocument();
});
});
Loading