diff --git a/README.md b/README.md
index 8a54e1dea4e..e40ff5073d8 100644
--- a/README.md
+++ b/README.md
@@ -143,6 +143,7 @@ These GitHub repositories provide supplementary resources for Rush Stack:
| Folder | Description |
| ------ | -----------|
| [/apps/lockfile-explorer-web](./apps/lockfile-explorer-web/) | Rush Lockfile Explorer: helper project for building the React web application component |
+| [/apps/rush-serve-dashboard](./apps/rush-serve-dashboard/) | Web dashboard for the Rush serve WebSocket protocol |
| [/build-tests-samples/heft-node-basic-tutorial](./build-tests-samples/heft-node-basic-tutorial/) | (Copy of sample project) Building this project is a regression test for Heft |
| [/build-tests-samples/heft-node-jest-tutorial](./build-tests-samples/heft-node-jest-tutorial/) | (Copy of sample project) Building this project is a regression test for Heft |
| [/build-tests-samples/heft-node-rig-tutorial](./build-tests-samples/heft-node-rig-tutorial/) | (Copy of sample project) Building this project is a regression test for Heft |
diff --git a/apps/rush-serve-dashboard/README.md b/apps/rush-serve-dashboard/README.md
new file mode 100644
index 00000000000..c77a76fee7d
--- /dev/null
+++ b/apps/rush-serve-dashboard/README.md
@@ -0,0 +1,15 @@
+# @rushstack/rush-serve-dashboard
+
+This private package provides the browser dashboard for `@rushstack/rush-serve-plugin`. Its emitted ESM
+assets are copied into the plugin package during the plugin build; it is not published independently.
+
+The dashboard displays Rush operation state in table and graph views, exposes controls supported by the
+plugin's WebSocket protocol, and shows operation logs. See the
+[rush-serve-plugin documentation](../../rush-plugins/rush-serve-plugin/README.md#dashboard) for configuration
+and usage.
+
+## Development
+
+From this folder, run `rushx start` to start the Heft development server or `rushx build` to build and test the
+project. From the repository root, run `rush build --to @rushstack/rush-serve-plugin` to validate the dashboard
+and the consuming plugin together.
diff --git a/apps/rush-serve-dashboard/config/heft.json b/apps/rush-serve-dashboard/config/heft.json
new file mode 100644
index 00000000000..55f215d764e
--- /dev/null
+++ b/apps/rush-serve-dashboard/config/heft.json
@@ -0,0 +1,28 @@
+{
+ "$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft.schema.json",
+
+ "extends": "local-web-rig/profiles/app/config/heft.json",
+
+ "phasesByName": {
+ "build": {
+ "tasksByName": {
+ "copy-dashboard-assets": {
+ "taskPlugin": {
+ "pluginPackage": "@rushstack/heft",
+ "pluginName": "copy-files-plugin",
+ "options": {
+ "copyOperations": [
+ {
+ "sourcePath": "src",
+ "destinationFolders": ["lib-esm"],
+ "fileExtensions": [".html"],
+ "hardlink": true
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/apps/rush-serve-dashboard/config/rig.json b/apps/rush-serve-dashboard/config/rig.json
new file mode 100644
index 00000000000..26f617ab3fc
--- /dev/null
+++ b/apps/rush-serve-dashboard/config/rig.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
+
+ "rigPackageName": "local-web-rig",
+ "rigProfile": "app"
+}
diff --git a/apps/rush-serve-dashboard/eslint.config.js b/apps/rush-serve-dashboard/eslint.config.js
new file mode 100644
index 00000000000..72fe2a29d25
--- /dev/null
+++ b/apps/rush-serve-dashboard/eslint.config.js
@@ -0,0 +1,16 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+const webAppProfile = require('local-web-rig/profiles/app/includes/eslint/flat/profile/web-app');
+
+module.exports = [
+ ...webAppProfile,
+ {
+ files: ['**/*.ts', '**/*.tsx'],
+ languageOptions: {
+ parserOptions: {
+ tsconfigRootDir: __dirname
+ }
+ }
+ }
+];
diff --git a/apps/rush-serve-dashboard/package.json b/apps/rush-serve-dashboard/package.json
new file mode 100644
index 00000000000..45813b1b61c
--- /dev/null
+++ b/apps/rush-serve-dashboard/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "@rushstack/rush-serve-dashboard",
+ "version": "0.0.0",
+ "description": "Web dashboard for the Rush serve WebSocket protocol",
+ "private": true,
+ "license": "MIT",
+ "scripts": {
+ "build": "heft test --clean",
+ "start": "heft start",
+ "_phase:build": "heft run --only build -- --clean",
+ "_phase:test": "heft run --only test -- --clean"
+ },
+ "devDependencies": {
+ "@rushstack/heft": "workspace:*",
+ "eslint": "~9.37.0",
+ "local-web-rig": "workspace:*"
+ },
+ "sideEffects": false
+}
diff --git a/apps/rush-serve-dashboard/src/dashboard.html b/apps/rush-serve-dashboard/src/dashboard.html
new file mode 100644
index 00000000000..8f1dfed8923
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/dashboard.html
@@ -0,0 +1,253 @@
+
+
+
+
+
+
+
+ Rush Serve Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+ βΈοΈ
+ Disconnected
+
+
Rush Serve Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Dependency Graph
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/rush-serve-dashboard/src/dashboard.ts b/apps/rush-serve-dashboard/src/dashboard.ts
new file mode 100644
index 00000000000..0c2e6510824
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/dashboard.ts
@@ -0,0 +1,521 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+import {
+ applyExecutionStates as applyExecutionStatesMutation,
+ patchOperationsFromPayload,
+ setOperationsFromPayload,
+ setQueuedStates,
+ toLastExecutionResultsMap
+} from './modules/dashboardMutations';
+import {
+ computeWsUrl as computeWebSocketUrl,
+ overallStatusText,
+ setConnected as setTopBarConnected,
+ showConnectingStatus,
+ updateDerivedUrlDisplay as updateTopBarDerivedUrlDisplay,
+ updateManagerState as updateTopBarManagerState,
+ updateStatusPill as updateTopBarStatusPill,
+ type ITopBarRefs
+} from './modules/topBar';
+import { createTerminalPaneController, type ITerminalPaneController } from './modules/terminalPane';
+import { createDashboardWebSocketController } from './modules/dashboardWebSocket';
+import { computeFilterSetsCore } from './modules/graphFiltering';
+import { createGraphViewController, graphState } from './modules/graphView';
+import { createGraphSelectionController } from './modules/graphSelection';
+import { createSelectionBarController } from './modules/selectionBar';
+import { createTableViewController } from './modules/tableView';
+import { createPhaseLegendController } from './modules/phaseLegend';
+import { wireLeftBarActions } from './modules/leftBar';
+import { wireMainBarActions } from './modules/mainBar';
+import { wireViewBar } from './modules/viewBar';
+import { loadDashboardUrlState, type DashboardFilter, type DashboardView } from './modules/urlState';
+import {
+ buildRunPolicyText,
+ buildTooltip,
+ computeDisplayStatus as computeDisplayStatusCore,
+ enabledGlyph,
+ getStatusColors,
+ statusEmoji
+} from './modules/statusHelpers';
+
+interface IOperationLogFileURLs {
+ text?: string;
+ error?: string;
+ jsonl?: string;
+}
+
+interface IOperationInfo {
+ name: string;
+ dependencies?: string[];
+ phaseName?: string;
+ packageName?: string;
+ noop?: boolean;
+ enabled?: string;
+ status?: string;
+ runInThisIteration?: boolean;
+ isActive?: boolean;
+ logFileURLs?: IOperationLogFileURLs;
+}
+
+interface IOperationExecutionState {
+ name: string;
+ status?: string;
+ runInThisIteration?: boolean;
+ isActive?: boolean;
+ logFileURLs?: IOperationLogFileURLs;
+}
+
+interface IDashboardGraphState {
+ status?: string;
+ debugMode?: boolean;
+ verbose?: boolean;
+ pauseNextIteration?: boolean;
+ parallelism?: number | string;
+ hasScheduledIteration?: boolean;
+}
+
+interface IDashboardSessionInfo {
+ actionName: string;
+ repositoryIdentifier: string;
+}
+
+interface IDashboardMessage {
+ event: string;
+ operations?: IOperationInfo[];
+ currentExecutionStates?: IOperationExecutionState[];
+ executionStates?: IOperationExecutionState[];
+ queuedStates?: IOperationExecutionState[];
+ graphState?: IDashboardGraphState;
+ resultByOperation?: IOperationExecutionState[];
+ status?: string;
+ sessionInfo?: IDashboardSessionInfo;
+ kind?: string;
+ text?: string;
+}
+
+interface IGraphViewControllerLike {
+ markGraphDirty(): void;
+ ensureGraph(): void;
+ updateGraph(): void;
+}
+
+const statusPill: HTMLElement = document.getElementById('status-pill') as HTMLElement;
+const statusEmojiEl: HTMLElement = document.getElementById('status-emoji') as HTMLElement;
+const connectBtn: HTMLButtonElement | undefined =
+ (document.getElementById('connect-btn') as HTMLButtonElement | null) ?? undefined;
+const appTitleEl: HTMLElement | undefined = document.getElementById('app-title') ?? undefined;
+const tableEl: HTMLTableElement = document.getElementById('operations-table') as HTMLTableElement;
+const tableHead: HTMLTableSectionElement | undefined = tableEl.querySelector('thead') ?? undefined;
+const tableBody: HTMLTableSectionElement | undefined = tableEl.querySelector('tbody') ?? undefined;
+const tableStats: HTMLElement | undefined = document.getElementById('table-stats') ?? undefined;
+const managerStateEl: HTMLElement | undefined = document.getElementById('graph-state') ?? undefined;
+const edgesSvg: SVGSVGElement = document.getElementById('edges') as unknown as SVGSVGElement;
+const graphEl: HTMLElement = document.getElementById('graph') as HTMLElement;
+const legendEl: HTMLElement | undefined = document.getElementById('graph-legend') ?? undefined;
+const phasePaneEl: HTMLElement | undefined = document.getElementById('phase-pane') ?? undefined;
+const phaseGroupsEl: HTMLElement | undefined =
+ phasePaneEl?.querySelector('.phase-groups') ?? undefined;
+const playPauseBtn: HTMLButtonElement | undefined =
+ (document.getElementById('play-pause-btn') as HTMLButtonElement | null) ?? undefined;
+const parallelismInput: HTMLInputElement | undefined =
+ (document.getElementById('parallelism-input') as HTMLInputElement | null) ?? undefined;
+const debugBtn: HTMLButtonElement | undefined =
+ (document.getElementById('debug-btn') as HTMLButtonElement | null) ?? undefined;
+const verboseBtn: HTMLButtonElement | undefined =
+ (document.getElementById('verbose-btn') as HTMLButtonElement | null) ?? undefined;
+const terminalEl: HTMLElement | undefined = document.getElementById('terminal') ?? undefined;
+const terminalBody: HTMLElement | undefined = document.getElementById('terminal-body') ?? undefined;
+const termClearBtn: HTMLButtonElement | undefined =
+ (document.getElementById('term-clear-btn') as HTMLButtonElement | null) ?? undefined;
+const termAutoScroll: HTMLInputElement | undefined =
+ (document.getElementById('term-autoscroll') as HTMLInputElement | null) ?? undefined;
+const termAutoscrollBtn: HTMLButtonElement | undefined =
+ (document.getElementById('term-autoscroll-btn') as HTMLButtonElement | null) ?? undefined;
+const toggleTerminalBtn: HTMLButtonElement | undefined =
+ (document.getElementById('toggle-terminal-btn') as HTMLButtonElement | null) ?? undefined;
+const resizerEl: HTMLElement | undefined = document.getElementById('resizer') ?? undefined;
+
+const terminalPane: ITerminalPaneController = createTerminalPaneController({
+ terminalEl,
+ terminalBody,
+ termClearBtn,
+ termAutoScrollCheckbox: termAutoScroll,
+ termAutoscrollBtn,
+ toggleTerminalBtn,
+ resizerEl
+});
+
+const topBarRefs: ITopBarRefs = {
+ connectBtn,
+ statusPill,
+ statusEmojiEl,
+ debugBtn,
+ verboseBtn,
+ playPauseBtn,
+ parallelismInput,
+ managerStateEl
+};
+
+const disabledControlIds: string[] = [
+ 'invalidate-btn',
+ 'close-runners-btn',
+ 'set-enabled-default-btn',
+ 'set-enabled-ignore-deps-btn',
+ 'set-enabled-disabled-btn',
+ 'expand-deps-btn',
+ 'expand-consumers-btn',
+ 'execute-btn',
+ 'abort-execution-btn',
+ 'clear-selection-btn',
+ 'debug-btn',
+ 'verbose-btn',
+ 'parallelism-input',
+ 'play-pause-btn'
+];
+
+const operations: Map = new Map();
+const executionStates: Map = new Map();
+const queuedStates: Map = new Map();
+let lastExecutionResults: Map = new Map();
+let selection: Set = new Set();
+let graphSettings: IDashboardGraphState | undefined;
+let currentView: DashboardView = 'table';
+let currentFilter: DashboardFilter = 'all';
+let searchQuery: string = '';
+let filteredOutNames: Set = new Set();
+let searchFilteredOutNames: Set = new Set();
+
+function computeDisplayStatus(op: IOperationInfo): string {
+ return computeDisplayStatusCore(op, executionStates, lastExecutionResults);
+}
+
+function computeVisibleOperations(): IOperationInfo[] {
+ const result: ReturnType = computeFilterSetsCore({
+ operations,
+ executionStates,
+ currentFilter,
+ searchQuery,
+ computeDisplayStatus
+ });
+ filteredOutNames = result.filteredOutNames;
+ searchFilteredOutNames = result.searchFilteredOutNames;
+ return result.visibleOperations;
+}
+
+const tableViewController: ReturnType = createTableViewController({
+ tableHead: tableHead || undefined,
+ tableBody: tableBody || undefined,
+ tableStats: tableStats || undefined,
+ getOperations: () => operations,
+ getFilteredOperations: () => computeVisibleOperations(),
+ getSelection: () => selection,
+ setSelection: (nextSelection: Set) => {
+ selection = nextSelection;
+ },
+ onSelectionMutated: () => {
+ updateSelectionUI();
+ render();
+ },
+ computeDisplayStatus,
+ enabledGlyph,
+ buildRunPolicyText,
+ buildTooltip,
+ statusEmoji,
+ overallStatusText
+});
+
+const phaseLegendController: ReturnType = createPhaseLegendController({
+ phaseGroupsEl: phaseGroupsEl || undefined,
+ legendEl: legendEl || undefined,
+ getOperations: () => operations,
+ getGraphVisibleNames: () =>
+ graphState.nodePositions.size ? new Set(graphState.nodePositions.keys()) : undefined,
+ computeDisplayStatus,
+ statusEmoji,
+ overallStatusText,
+ getStatusColors
+});
+
+function selectionChanged(): void {
+ updateSelectionUI();
+ render();
+}
+
+const graphSelectionController: ReturnType =
+ createGraphSelectionController({
+ graphEl,
+ getCurrentView: () => currentView,
+ getSelection: () => selection,
+ setSelection: (nextSelection: Set) => {
+ selection = nextSelection;
+ },
+ getOperations: () => operations,
+ getGraphNodePositions: () => graphState.nodePositions,
+ graphNodeWidth: 28,
+ graphNodeHeight: 28,
+ onSelectionChanged: selectionChanged,
+ onLiveSelectionChanged: updateGraph
+ });
+
+function singleSelect(name: string): void {
+ graphSelectionController.singleSelect(name);
+}
+
+function toggleSelect(name: string): void {
+ graphSelectionController.toggleSelect(name);
+}
+
+const graphViewController: IGraphViewControllerLike = createGraphViewController({
+ graphEl,
+ edgesSvg,
+ getOperations: () => operations,
+ getExecutionStates: () => executionStates,
+ getQueuedStates: () => queuedStates,
+ getSelection: () => selection,
+ getFilteredOutNames: () => filteredOutNames,
+ getSearchFilteredOutNames: () => searchFilteredOutNames,
+ getLastExecutionResults: () => lastExecutionResults,
+ getComputeDisplayStatus: computeDisplayStatus,
+ getStatusEmoji: statusEmoji,
+ getOverallStatusText: overallStatusText,
+ renderPhaseLegend: () => phaseLegendController.renderAll(),
+ singleSelect,
+ toggleSelect
+});
+
+graphSelectionController.wireGraphMarqueeSelection();
+
+function markGraphDirty(): void {
+ graphViewController.markGraphDirty();
+ if (currentView === 'graph') {
+ ensureGraph();
+ }
+}
+
+function ensureGraph(): void {
+ graphViewController.ensureGraph();
+}
+
+function updateGraph(): void {
+ graphViewController.updateGraph();
+}
+
+function renderTable(): void {
+ tableViewController.renderTable();
+}
+
+function render(): void {
+ if (currentView === 'table') {
+ renderTable();
+ } else {
+ ensureGraph();
+ }
+ updateSelectionUI();
+}
+
+function setConnected(connected: boolean): void {
+ setTopBarConnected(topBarRefs, connected, updateSelectionUI, disabledControlIds);
+}
+
+function updateDerivedUrlDisplay(): void {
+ updateTopBarDerivedUrlDisplay(connectBtn);
+}
+
+function updateManagerState(): void {
+ if (!graphSettings) return;
+ updateTopBarManagerState(topBarRefs, graphSettings);
+}
+
+function log(message: string): void {
+ const time: string = new Date().toLocaleTimeString();
+ window.console.log('[' + time + '] ' + message);
+}
+
+const socketController: ReturnType =
+ createDashboardWebSocketController({
+ getUrl: () => computeWebSocketUrl(window.location),
+ onConnecting: () => {
+ showConnectingStatus(statusPill, statusEmojiEl, statusEmoji);
+ },
+ onConnectedStateChange: (connected: boolean) => {
+ setConnected(connected);
+ },
+ onOpen: () => {
+ updateStatusPill();
+ },
+ onClose: () => {
+ updateStatusPill();
+ },
+ onError: (event: Event) => {
+ log('WebSocket error: ' + event.type);
+ },
+ onParsedMessage: (message: unknown) => {
+ handleMessage(message as IDashboardMessage);
+ },
+ onParseError: (error: unknown) => {
+ log('Bad JSON: ' + String(error));
+ },
+ onLog: log
+ });
+
+function updateStatusPill(): void {
+ updateTopBarStatusPill(topBarRefs, socketController.getSocket(), graphSettings, statusEmoji);
+}
+
+const selectionBarController: ReturnType = createSelectionBarController({
+ getSelection: () => selection,
+ getCurrentView: () => currentView,
+ isConnected: () => socketController.isConnected()
+});
+
+function connect(): void {
+ socketController.connect();
+}
+
+function disconnect(): void {
+ socketController.disconnect();
+}
+
+function sendCommand(cmd: unknown): void {
+ socketController.sendCommand(cmd);
+}
+
+function handleMessage(msg: IDashboardMessage): void {
+ switch (msg.event) {
+ case 'sync': {
+ setOperationsFromPayload(operations, msg.operations || []);
+ executionStates.clear();
+ applyExecutionStatesMutation(operations, executionStates, msg.currentExecutionStates || []);
+ setQueuedStates(queuedStates, msg.queuedStates || []);
+ graphSettings = msg.graphState;
+ lastExecutionResults = toLastExecutionResultsMap(msg.resultByOperation || []);
+ if (appTitleEl && msg.sessionInfo) {
+ const title: string = `${msg.sessionInfo.actionName} β ${msg.sessionInfo.repositoryIdentifier}`;
+ appTitleEl.textContent = title;
+ document.title = title;
+ }
+ markGraphDirty();
+ break;
+ }
+ case 'sync-operations': {
+ patchOperationsFromPayload(operations, msg.operations || []);
+ markGraphDirty();
+ break;
+ }
+ case 'sync-graph-state': {
+ graphSettings = msg.graphState;
+ break;
+ }
+ case 'iteration-scheduled': {
+ setQueuedStates(queuedStates, msg.queuedStates || []);
+ break;
+ }
+ case 'before-execute':
+ case 'status-change': {
+ applyExecutionStatesMutation(operations, executionStates, msg.executionStates || []);
+ break;
+ }
+ case 'after-execute': {
+ applyExecutionStatesMutation(operations, executionStates, msg.executionStates || []);
+ lastExecutionResults = toLastExecutionResultsMap(msg.resultByOperation || []);
+ if (graphSettings && msg.status) {
+ graphSettings.status = msg.status;
+ }
+ break;
+ }
+ case 'terminal-chunk': {
+ terminalPane.appendChunk(msg.kind, msg.text);
+ break;
+ }
+ }
+
+ updateManagerState();
+ updateStatusPill();
+ render();
+}
+
+function updateSelectionUI(): void {
+ selectionBarController.updateSelectionUI();
+}
+
+function expandSelectionDependencies(): void {
+ graphSelectionController.expandSelectionDependencies();
+}
+
+function expandSelectionConsumers(): void {
+ graphSelectionController.expandSelectionConsumers();
+}
+
+function wireActions(): void {
+ wireMainBarActions({
+ connect,
+ disconnect,
+ isConnected: () => socketController.isConnected(),
+ sendCommand,
+ getGraphSettings: () => graphSettings,
+ debugBtn,
+ verboseBtn,
+ parallelismInput,
+ playPauseBtn,
+ getOperationNames: () => Array.from(operations.keys()),
+ setSelection: (nextSelection: Set) => {
+ selection = nextSelection;
+ },
+ clearSelection: () => {
+ selection.clear();
+ },
+ hasSelection: () => selection.size > 0,
+ render
+ });
+
+ wireLeftBarActions({
+ sendCommand,
+ getSelection: () => selection,
+ clearSelectionAndRender: () => {
+ if (!selection.size) return;
+ selection.clear();
+ render();
+ },
+ expandSelectionDependencies,
+ expandSelectionConsumers
+ });
+
+ wireViewBar({
+ getView: () => currentView,
+ setView: (nextView: DashboardView) => {
+ currentView = nextView;
+ },
+ getFilter: () => currentFilter,
+ setFilter: (nextFilter: DashboardFilter) => {
+ currentFilter = nextFilter;
+ },
+ setSearchQuery: (nextSearchQuery: string) => {
+ searchQuery = nextSearchQuery;
+ },
+ markGraphDirty,
+ render
+ });
+}
+function init(): void {
+ const urlState: ReturnType = loadDashboardUrlState(window.location.search);
+ currentView = urlState.view;
+ currentFilter = urlState.filter;
+
+ wireActions();
+ updateDerivedUrlDisplay();
+ updateSelectionUI();
+ updateManagerState();
+ updateStatusPill();
+ connect();
+ (
+ window as Window & {
+ __rushServeDemo?: { operations: Map; selection: Set };
+ }
+ ).__rushServeDemo = {
+ operations,
+ selection
+ };
+}
+
+init();
diff --git a/apps/rush-serve-dashboard/src/modules/ansiSgrParser.ts b/apps/rush-serve-dashboard/src/modules/ansiSgrParser.ts
new file mode 100644
index 00000000000..68ac598e6cd
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/modules/ansiSgrParser.ts
@@ -0,0 +1,143 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+export interface IAnsiSegment {
+ text: string;
+ style: string;
+}
+
+interface IAnsiState {
+ bold: boolean;
+ underline: boolean;
+ inverse: boolean;
+ fg?: string;
+ bg?: string;
+}
+
+export class AnsiSgrParser {
+ private readonly _state: IAnsiState = {
+ bold: false,
+ underline: false,
+ inverse: false,
+ fg: undefined,
+ bg: undefined
+ };
+
+ public process(input: string): IAnsiSegment[] {
+ let lastIndex: number = 0;
+ let searchIndex: number = 0;
+ const segments: IAnsiSegment[] = [];
+
+ const pushSegmentIfText = (text: string): void => {
+ if (!text) return;
+ const style: string = this._ansiStateToStyle(this._state);
+ segments.push({ text, style });
+ };
+
+ while (searchIndex < input.length) {
+ const escapeIndex: number = input.indexOf('\u001b[', searchIndex);
+ if (escapeIndex < 0) break;
+
+ const suffixMatch: RegExpExecArray | undefined =
+ /^[0-9;]*m/.exec(input.slice(escapeIndex + 2)) ?? undefined;
+ if (!suffixMatch) {
+ searchIndex = escapeIndex + 2;
+ continue;
+ }
+
+ if (escapeIndex > lastIndex) {
+ pushSegmentIfText(input.slice(lastIndex, escapeIndex));
+ }
+
+ const sequenceEnd: number = escapeIndex + 2 + suffixMatch[0].length;
+ const seq: string = input.slice(escapeIndex, sequenceEnd);
+ try {
+ this._applySgr(this._parseSgrParams(seq));
+ } catch {
+ // Ignore malformed control sequences.
+ }
+
+ lastIndex = sequenceEnd;
+ searchIndex = sequenceEnd;
+ }
+
+ if (lastIndex < input.length) {
+ pushSegmentIfText(input.slice(lastIndex));
+ }
+
+ return segments;
+ }
+
+ private _parseSgrParams(seq: string): number[] {
+ let s: string = seq;
+ if (s.startsWith('\u001b[')) {
+ s = s.slice(2);
+ }
+ if (s.endsWith('m')) {
+ s = s.slice(0, -1);
+ }
+ if (!s) return [0];
+ return s.split(';').map((p) => Number(p || 0));
+ }
+
+ private _applySgr(params: number[]): void {
+ if (!params || !params.length) params = [0];
+
+ for (const p of params) {
+ if (p === 0) {
+ this._state.bold = false;
+ this._state.underline = false;
+ this._state.inverse = false;
+ this._state.fg = undefined;
+ this._state.bg = undefined;
+ } else if (p === 1) {
+ this._state.bold = true;
+ } else if (p === 4) {
+ this._state.underline = true;
+ } else if (p === 7) {
+ this._state.inverse = true;
+ } else if (p === 22) {
+ this._state.bold = false;
+ } else if (p === 24) {
+ this._state.underline = false;
+ } else if (p >= 30 && p <= 37) {
+ this._state.fg = this._sgrColorToCss(p - 30, false);
+ } else if (p === 39) {
+ this._state.fg = undefined;
+ } else if (p >= 40 && p <= 47) {
+ this._state.bg = this._sgrColorToCss(p - 40, false);
+ } else if (p === 49) {
+ this._state.bg = undefined;
+ } else if (p >= 90 && p <= 97) {
+ this._state.fg = this._sgrColorToCss(p - 90, true);
+ } else if (p >= 100 && p <= 107) {
+ this._state.bg = this._sgrColorToCss(p - 100, true);
+ }
+ }
+ }
+
+ private _sgrColorToCss(idx: number, bright: boolean): string | undefined {
+ const base: string[] = ['#000000', '#a00', '#0a0', '#aa0', '#00a', '#a0a', '#0aa', '#ddd'];
+ const brightMap: string[] = [
+ '#555',
+ '#ff5555',
+ '#55ff55',
+ '#ffff55',
+ '#5555ff',
+ '#ff55ff',
+ '#55ffff',
+ '#fff'
+ ];
+ return bright ? brightMap[idx] || base[idx] : base[idx];
+ }
+
+ private _ansiStateToStyle(state: IAnsiState): string {
+ const styles: string[] = [];
+ if (state.fg) styles.push('color: ' + state.fg);
+ if (state.bg) styles.push('background-color: ' + state.bg);
+ if (state.bold) styles.push('font-weight: 700');
+ if (state.underline) styles.push('text-decoration: underline');
+ if (state.inverse) styles.push('filter: invert(100%)');
+ return styles.join('; ');
+ }
+}
diff --git a/apps/rush-serve-dashboard/src/modules/dashboardMutations.ts b/apps/rush-serve-dashboard/src/modules/dashboardMutations.ts
new file mode 100644
index 00000000000..c3c1f4bf251
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/modules/dashboardMutations.ts
@@ -0,0 +1,81 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+interface IOperationLogFileURLs {
+ text?: string;
+ error?: string;
+ jsonl?: string;
+}
+
+interface IOperationData {
+ name: string;
+ isActive?: boolean;
+ status?: string;
+ runInThisIteration?: boolean;
+ logFileURLs?: IOperationLogFileURLs;
+}
+
+interface IStateEntry {
+ name: string;
+ isActive?: boolean;
+ status?: string;
+ runInThisIteration?: boolean;
+ logFileURLs?: IOperationLogFileURLs;
+}
+
+export function applyExecutionStates(
+ operations: Map,
+ executionStates: Map,
+ stateArray: IStateEntry[]
+): void {
+ if (!stateArray) return;
+
+ stateArray.forEach((stateEntry) => {
+ executionStates.set(stateEntry.name, stateEntry);
+
+ const op: IOperationData | undefined = operations.get(stateEntry.name);
+ if (!op) return;
+
+ op.isActive = stateEntry.isActive;
+ op.status = stateEntry.status || op.status;
+ op.runInThisIteration = stateEntry.runInThisIteration;
+ op.logFileURLs = stateEntry.logFileURLs;
+ });
+}
+
+export function setQueuedStates(queuedStates: Map, stateArray: IStateEntry[]): void {
+ queuedStates.clear();
+ if (!stateArray) return;
+
+ stateArray.forEach((stateEntry) => {
+ queuedStates.set(stateEntry.name, stateEntry);
+ });
+}
+
+export function setOperationsFromPayload(
+ operations: Map,
+ operationArray: IOperationData[]
+): void {
+ operations.clear();
+ operationArray.forEach((op) => operations.set(op.name, op));
+}
+
+export function patchOperationsFromPayload(
+ operations: Map,
+ operationArray: IOperationData[]
+): void {
+ operationArray.forEach((op) => operations.set(op.name, op));
+}
+
+export function toLastExecutionResultsMap(
+ lastExecutionResultsArray: IStateEntry[] | undefined
+): Map {
+ const result: Map = new Map();
+ if (!lastExecutionResultsArray) return result;
+
+ lastExecutionResultsArray.forEach((entry) => {
+ result.set(entry.name, entry);
+ });
+
+ return result;
+}
diff --git a/apps/rush-serve-dashboard/src/modules/dashboardWebSocket.ts b/apps/rush-serve-dashboard/src/modules/dashboardWebSocket.ts
new file mode 100644
index 00000000000..83c6e9cb8dd
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/modules/dashboardWebSocket.ts
@@ -0,0 +1,123 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+export interface IDashboardWebSocketControllerOptions {
+ getUrl: () => string;
+ onConnecting: (url: string) => void;
+ onConnectedStateChange: (connected: boolean) => void;
+ onOpen: () => void;
+ onClose: () => void;
+ onError: (event: Event) => void;
+ onParsedMessage: (message: unknown) => void;
+ onParseError: (error: unknown) => void;
+ onLog: (message: string) => void;
+}
+
+export interface IDashboardWebSocketController {
+ connect: () => void;
+ disconnect: () => void;
+ sendCommand: (command: unknown) => void;
+ isConnected: () => boolean;
+ getSocket: () => WebSocket | undefined;
+}
+
+export function createDashboardWebSocketController(
+ options: IDashboardWebSocketControllerOptions
+): IDashboardWebSocketController {
+ let ws: WebSocket | undefined;
+ let reconnectTimer: ReturnType | undefined;
+ let manualDisconnect: boolean = false;
+
+ const isConnected = (): boolean => !!ws && ws.readyState === WebSocket.OPEN;
+ const getSocket = (): WebSocket | undefined => ws;
+
+ const scheduleReconnect = (): void => {
+ if (reconnectTimer) return;
+ reconnectTimer = setTimeout(() => {
+ reconnectTimer = undefined;
+ if (!manualDisconnect) connect();
+ }, 4000);
+ };
+
+ function connect(): void {
+ if (ws && ws.readyState === WebSocket.OPEN) return;
+
+ if (reconnectTimer) {
+ clearTimeout(reconnectTimer);
+ reconnectTimer = undefined;
+ }
+
+ manualDisconnect = false;
+ const url: string = options.getUrl();
+ if (!url) return;
+
+ options.onConnecting(url);
+ options.onLog('Attempting connection to ' + url);
+
+ try {
+ ws = new WebSocket(url);
+ } catch (error: unknown) {
+ options.onLog('WebSocket creation failed: ' + String(error));
+ scheduleReconnect();
+ return;
+ }
+
+ options.onConnectedStateChange(false);
+
+ ws.addEventListener('open', () => {
+ options.onLog('Connected');
+ options.onConnectedStateChange(true);
+ options.onOpen();
+ });
+
+ ws.addEventListener('close', () => {
+ options.onLog('Disconnected');
+ options.onConnectedStateChange(false);
+ ws = undefined;
+ options.onClose();
+ if (!manualDisconnect) scheduleReconnect();
+ });
+
+ ws.addEventListener('error', (event: Event) => {
+ options.onError(event);
+ });
+
+ ws.addEventListener('message', (ev: MessageEvent) => {
+ try {
+ options.onParsedMessage(JSON.parse(ev.data));
+ } catch (error: unknown) {
+ options.onParseError(error);
+ }
+ });
+ }
+
+ const disconnect = (): void => {
+ manualDisconnect = true;
+ if (reconnectTimer) {
+ clearTimeout(reconnectTimer);
+ reconnectTimer = undefined;
+ }
+ if (ws) {
+ try {
+ ws.close();
+ } catch {
+ // ignore close failures
+ }
+ ws = undefined;
+ }
+ };
+
+ const sendCommand = (command: unknown): void => {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify(command));
+ }
+ };
+
+ return {
+ connect,
+ disconnect,
+ sendCommand,
+ isConnected,
+ getSocket
+ };
+}
diff --git a/apps/rush-serve-dashboard/src/modules/graphFiltering.ts b/apps/rush-serve-dashboard/src/modules/graphFiltering.ts
new file mode 100644
index 00000000000..66ba48fa8b8
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/modules/graphFiltering.ts
@@ -0,0 +1,199 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+interface IOperationLogFileURLs {
+ text?: string;
+ error?: string;
+ jsonl?: string;
+}
+
+interface IOperationData {
+ name: string;
+ dependencies?: string[];
+ noop?: boolean;
+ runInThisIteration?: boolean;
+ status?: string;
+ isActive?: boolean;
+ logFileURLs?: IOperationLogFileURLs;
+}
+
+interface IExecutionState {
+ name: string;
+ status?: string;
+ runInThisIteration?: boolean;
+ isActive?: boolean;
+ logFileURLs?: IOperationLogFileURLs;
+}
+
+export interface IComputeFilterSetsOptions {
+ operations: Map;
+ executionStates: Map;
+ currentFilter: 'all' | 'failed-warn';
+ searchQuery: string;
+ computeDisplayStatus: (op: IOperationData) => string;
+}
+
+export interface IComputeFilterSetsResult {
+ visibleOperations: IOperationData[];
+ filteredOutNames: Set;
+ searchFilteredOutNames: Set;
+}
+
+export function computeFilterSetsCore(options: IComputeFilterSetsOptions): IComputeFilterSetsResult {
+ const { operations, executionStates, currentFilter, searchQuery, computeDisplayStatus } = options;
+
+ const filteredOutNames: Set = new Set();
+ const searchFilteredOutNames: Set = new Set();
+ const visibleOperations: IOperationData[] = [];
+ const query: string = searchQuery.trim().toLowerCase();
+
+ for (const op of operations.values()) {
+ const state: IExecutionState | undefined = executionStates.get(op.name) || undefined;
+
+ // Merge dynamic fields so rendering logic can treat operation rows uniformly.
+ op.runInThisIteration = state?.runInThisIteration;
+ op.status = state?.status || op.status;
+ op.isActive = state?.isActive;
+ op.logFileURLs = state?.logFileURLs;
+
+ const effectiveStatus: string = computeDisplayStatus(op);
+ if (currentFilter === 'failed-warn') {
+ const includeInFailedWarn: boolean =
+ effectiveStatus === 'Failure' || effectiveStatus === 'SuccessWithWarning';
+ if (!includeInFailedWarn) {
+ filteredOutNames.add(op.name);
+ continue;
+ }
+ }
+
+ if (query && !op.name.toLowerCase().includes(query)) {
+ searchFilteredOutNames.add(op.name);
+ continue;
+ }
+
+ visibleOperations.push(op);
+ }
+
+ return {
+ visibleOperations,
+ filteredOutNames,
+ searchFilteredOutNames
+ };
+}
+
+export function pruneGraphOperations(baseOperations: IOperationData[]): IOperationData[] {
+ if (!baseOperations.length) return baseOperations;
+
+ const byName: Map = new Map();
+ baseOperations.forEach((op) => byName.set(op.name, op));
+
+ const dependents: Map> = new Map();
+ baseOperations.forEach((op) => {
+ (op.dependencies || []).forEach((dependencyName: string) => {
+ if (!byName.has(dependencyName)) return;
+ let setForDependency: Set | undefined = dependents.get(dependencyName);
+ if (!setForDependency) {
+ setForDependency = new Set();
+ dependents.set(dependencyName, setForDependency);
+ }
+ setForDependency.add(op.name);
+ });
+ });
+
+ const active: Set = new Set(baseOperations.map((op) => op.name));
+ const noopSet: Set = new Set(baseOperations.filter((op) => op.noop).map((op) => op.name));
+
+ const incomingCount: Map = new Map();
+ const outgoingCount: Map = new Map();
+ baseOperations.forEach((op) => {
+ incomingCount.set(op.name, (dependents.get(op.name) || new Set()).size);
+ const outgoing: number = (op.dependencies || []).filter((dependencyName: string) =>
+ byName.has(dependencyName)
+ ).length;
+ outgoingCount.set(op.name, outgoing);
+ });
+
+ const queue: string[] = [];
+ active.forEach((nodeName) => {
+ if (!noopSet.has(nodeName)) return;
+ const incoming: number = incomingCount.get(nodeName) || 0;
+ const outgoing: number = outgoingCount.get(nodeName) || 0;
+ if (incoming === 0 || incoming === 1 || outgoing === 1) queue.push(nodeName);
+ });
+
+ while (queue.length) {
+ const nodeName: string | undefined = queue.pop();
+ if (!nodeName || !active.has(nodeName)) continue;
+
+ active.delete(nodeName);
+ const op: IOperationData | undefined = byName.get(nodeName);
+ if (!op) continue;
+
+ for (const dependencyName of op.dependencies || []) {
+ if (!active.has(dependencyName)) continue;
+ const previous: number = incomingCount.get(dependencyName) || 0;
+ incomingCount.set(dependencyName, Math.max(0, previous - 1));
+ const incoming: number = incomingCount.get(dependencyName) || 0;
+ const outgoing: number = outgoingCount.get(dependencyName) || 0;
+ if (noopSet.has(dependencyName) && (incoming === 0 || incoming === 1 || outgoing === 1)) {
+ queue.push(dependencyName);
+ }
+ }
+
+ const dependentNodes: Set = dependents.get(nodeName) || new Set();
+ for (const dependentName of dependentNodes) {
+ if (!active.has(dependentName)) continue;
+ const previousOutgoing: number = outgoingCount.get(dependentName) || 0;
+ outgoingCount.set(dependentName, Math.max(0, previousOutgoing - 1));
+ const incoming: number = incomingCount.get(dependentName) || 0;
+ const outgoing: number = outgoingCount.get(dependentName) || 0;
+ if (noopSet.has(dependentName) && (incoming === 0 || incoming === 1 || outgoing === 1)) {
+ queue.push(dependentName);
+ }
+ }
+ }
+
+ const resolvedMemo: Map> = new Map();
+ function resolveDeps(nodeName: string, seen: Set): Set {
+ const cached: Set | undefined = resolvedMemo.get(nodeName);
+ if (cached) return cached;
+
+ if (seen.has(nodeName)) return new Set();
+ seen.add(nodeName);
+
+ const op: IOperationData | undefined = byName.get(nodeName);
+ const resolved: Set = new Set();
+ if (!op) return resolved;
+
+ for (const dependencyName of op.dependencies || []) {
+ if (!byName.has(dependencyName)) continue;
+ if (active.has(dependencyName)) {
+ resolved.add(dependencyName);
+ } else {
+ const subResolved: Set = resolveDeps(dependencyName, seen);
+ for (const item of subResolved) resolved.add(item);
+ }
+ }
+
+ seen.delete(nodeName);
+ resolvedMemo.set(nodeName, resolved);
+ return resolved;
+ }
+
+ return baseOperations
+ .filter((op) => active.has(op.name))
+ .map((op) => {
+ const dependencies: Set = new Set();
+ for (const dependencyName of op.dependencies || []) {
+ if (!byName.has(dependencyName)) continue;
+ if (active.has(dependencyName)) {
+ dependencies.add(dependencyName);
+ } else {
+ const subResolved: Set = resolveDeps(dependencyName, new Set());
+ for (const item of subResolved) dependencies.add(item);
+ }
+ }
+
+ return Object.assign({}, op, { dependencies: Array.from(dependencies) });
+ });
+}
diff --git a/apps/rush-serve-dashboard/src/modules/graphSelection.ts b/apps/rush-serve-dashboard/src/modules/graphSelection.ts
new file mode 100644
index 00000000000..e7381664e45
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/modules/graphSelection.ts
@@ -0,0 +1,244 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+interface IOperationInfo {
+ name: string;
+ dependencies?: string[];
+}
+
+interface IPoint {
+ x: number;
+ y: number;
+}
+
+export interface IGraphSelectionControllerOptions {
+ graphEl: HTMLElement;
+ getCurrentView: () => string;
+ getSelection: () => Set;
+ setSelection: (nextSelection: Set) => void;
+ getOperations: () => Map;
+ getGraphNodePositions: () => Map;
+ graphNodeWidth: number;
+ graphNodeHeight: number;
+ onSelectionChanged: () => void;
+ onLiveSelectionChanged: () => void;
+}
+
+export interface IGraphSelectionController {
+ singleSelect: (name: string) => void;
+ toggleSelect: (name: string) => void;
+ expandSelectionDependencies: () => void;
+ expandSelectionConsumers: () => void;
+ wireGraphMarqueeSelection: () => void;
+}
+
+export function createGraphSelectionController(
+ options: IGraphSelectionControllerOptions
+): IGraphSelectionController {
+ let graphMarqueeEl: HTMLDivElement | null = null;
+ let dragSelecting: boolean = false;
+ let dragStart: IPoint | null = null;
+ let dragLast: IPoint | null = null;
+ let preDragSelection: Set | null = null;
+ let dragModifierMode: 'replace' | 'add' | 'subtract' = 'replace';
+
+ const _selectionChanged = (): void => {
+ options.onSelectionChanged();
+ };
+
+ const _graphPointFromEvent = (e: MouseEvent): IPoint => {
+ const rect: DOMRect = options.graphEl.getBoundingClientRect();
+ return {
+ x: e.clientX - rect.left + options.graphEl.scrollLeft,
+ y: e.clientY - rect.top + options.graphEl.scrollTop
+ };
+ };
+
+ const _updateDragModifierMode = (e: MouseEvent | KeyboardEvent): void => {
+ if (e.altKey) dragModifierMode = 'subtract';
+ else if (e.metaKey || e.ctrlKey || e.shiftKey) dragModifierMode = 'add';
+ else dragModifierMode = 'replace';
+ };
+
+ const _updateMarquee = (): void => {
+ if (!dragSelecting || !graphMarqueeEl || !dragStart || !dragLast) return;
+
+ const x1: number = Math.min(dragStart.x, dragLast.x);
+ const y1: number = Math.min(dragStart.y, dragLast.y);
+ const x2: number = Math.max(dragStart.x, dragLast.x);
+ const y2: number = Math.max(dragStart.y, dragLast.y);
+
+ graphMarqueeEl.style.left = x1 + 'px';
+ graphMarqueeEl.style.top = y1 + 'px';
+ graphMarqueeEl.style.width = x2 - x1 + 'px';
+ graphMarqueeEl.style.height = y2 - y1 + 'px';
+
+ const newlySelected: Set = new Set();
+ for (const [name, pos] of options.getGraphNodePositions().entries()) {
+ const nx1: number = pos.x;
+ const ny1: number = pos.y;
+ const nx2: number = pos.x + options.graphNodeWidth;
+ const ny2: number = pos.y + options.graphNodeHeight;
+ if (nx2 < x1 || nx1 > x2 || ny2 < y1 || ny1 > y2) continue;
+ newlySelected.add(name);
+ }
+
+ let nextSelection: Set;
+ if (dragModifierMode === 'add') {
+ nextSelection = new Set(preDragSelection || []);
+ newlySelected.forEach((name) => nextSelection.add(name));
+ } else if (dragModifierMode === 'subtract') {
+ nextSelection = new Set(preDragSelection || []);
+ newlySelected.forEach((name) => nextSelection.delete(name));
+ } else {
+ nextSelection = newlySelected;
+ }
+
+ options.setSelection(nextSelection);
+ options.onLiveSelectionChanged();
+ };
+
+ const _beginDragSelection = (e: MouseEvent): void => {
+ if (options.getCurrentView() !== 'graph' || e.button !== 0) return;
+ const target: Element | null = e.target as Element | null;
+ if (target && target.closest && target.closest('.op-node')) return;
+
+ dragSelecting = true;
+ dragStart = _graphPointFromEvent(e);
+ dragLast = dragStart;
+ preDragSelection = new Set(options.getSelection());
+ graphMarqueeEl = document.createElement('div');
+ graphMarqueeEl.className = 'graph-marquee';
+ options.graphEl.appendChild(graphMarqueeEl);
+ _updateMarquee();
+ e.preventDefault();
+ };
+
+ const _wireGraphMarqueeSelection = (): void => {
+ options.graphEl.addEventListener('mousedown', (e: MouseEvent) => {
+ _updateDragModifierMode(e);
+ _beginDragSelection(e);
+ });
+
+ options.graphEl.addEventListener('mousemove', (e: MouseEvent) => {
+ if (!dragSelecting) return;
+ dragLast = _graphPointFromEvent(e);
+ _updateDragModifierMode(e);
+ _updateMarquee();
+ e.preventDefault();
+ });
+
+ window.addEventListener('mouseup', () => {
+ if (!dragSelecting) return;
+ dragSelecting = false;
+ if (graphMarqueeEl) {
+ graphMarqueeEl.remove();
+ graphMarqueeEl = null;
+ }
+ dragStart = null;
+ dragLast = null;
+ preDragSelection = null;
+ });
+
+ window.addEventListener('keydown', (e: KeyboardEvent) => {
+ if (dragSelecting) {
+ _updateDragModifierMode(e);
+ _updateMarquee();
+ }
+ });
+
+ window.addEventListener('keyup', (e: KeyboardEvent) => {
+ if (dragSelecting) {
+ _updateDragModifierMode(e);
+ _updateMarquee();
+ }
+ });
+ };
+
+ const _singleSelect = (name: string): void => {
+ options.setSelection(new Set([name]));
+ _selectionChanged();
+ };
+
+ const _toggleSelect = (name: string): void => {
+ const nextSelection: Set = new Set(options.getSelection());
+ if (nextSelection.has(name)) nextSelection.delete(name);
+ else nextSelection.add(name);
+ options.setSelection(nextSelection);
+ _selectionChanged();
+ };
+
+ const _expandSelectionDependencies = (): void => {
+ const currentSelection: Set = options.getSelection();
+ if (!currentSelection.size) return;
+
+ const queue: string[] = [...currentSelection];
+ const seen: Set = new Set(currentSelection);
+ const operations: Map = options.getOperations();
+
+ while (queue.length) {
+ const name: string | undefined = queue.shift();
+ if (!name) continue;
+ const op: IOperationInfo | undefined = operations.get(name);
+ if (!op) continue;
+ for (const dep of op.dependencies || []) {
+ if (!seen.has(dep) && operations.has(dep)) {
+ seen.add(dep);
+ queue.push(dep);
+ }
+ }
+ }
+
+ if (seen.size !== currentSelection.size) {
+ options.setSelection(seen);
+ _selectionChanged();
+ }
+ };
+
+ const _expandSelectionConsumers = (): void => {
+ const currentSelection: Set = options.getSelection();
+ if (!currentSelection.size) return;
+
+ const operations: Map = options.getOperations();
+ const dependents: Map> = new Map>();
+ for (const op of operations.values()) {
+ for (const dep of op.dependencies || []) {
+ if (!operations.has(dep)) continue;
+ let setForDep: Set | undefined = dependents.get(dep);
+ if (!setForDep) {
+ setForDep = new Set();
+ dependents.set(dep, setForDep);
+ }
+ setForDep.add(op.name);
+ }
+ }
+
+ const queue: string[] = [...currentSelection];
+ const seen: Set = new Set(currentSelection);
+ while (queue.length) {
+ const name: string | undefined = queue.shift();
+ if (!name) continue;
+ const consumers: Set | undefined = dependents.get(name);
+ if (!consumers) continue;
+ for (const consumer of consumers) {
+ if (!seen.has(consumer)) {
+ seen.add(consumer);
+ queue.push(consumer);
+ }
+ }
+ }
+
+ if (seen.size !== currentSelection.size) {
+ options.setSelection(seen);
+ _selectionChanged();
+ }
+ };
+
+ return {
+ singleSelect: _singleSelect,
+ toggleSelect: _toggleSelect,
+ expandSelectionDependencies: _expandSelectionDependencies,
+ expandSelectionConsumers: _expandSelectionConsumers,
+ wireGraphMarqueeSelection: _wireGraphMarqueeSelection
+ };
+}
diff --git a/apps/rush-serve-dashboard/src/modules/graphView.ts b/apps/rush-serve-dashboard/src/modules/graphView.ts
new file mode 100644
index 00000000000..47bd7d602d9
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/modules/graphView.ts
@@ -0,0 +1,563 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+import { pruneGraphOperations } from './graphFiltering';
+
+interface IOperationLogFileURLs {
+ text?: string;
+ error?: string;
+ jsonl?: string;
+}
+
+interface IOperationInfo {
+ name: string;
+ dependencies?: string[];
+ phaseName?: string;
+ packageName?: string;
+ noop?: boolean;
+ enabled?: string;
+ status?: string;
+ runInThisIteration?: boolean;
+ isActive?: boolean;
+ logFileURLs?: IOperationLogFileURLs;
+}
+
+interface IOperationExecutionState {
+ name: string;
+ status?: string;
+ runInThisIteration?: boolean;
+ isActive?: boolean;
+ logFileURLs?: IOperationLogFileURLs;
+}
+
+export interface IPoint {
+ x: number;
+ y: number;
+}
+
+export interface IGraphEdgeRecord {
+ path: SVGPathElement;
+ from: string;
+ to: string;
+}
+
+export interface IGraphState {
+ nodePositions: Map;
+ nodeStatus: Map;
+ nodeElements: Map;
+ edgeElements: IGraphEdgeRecord[];
+}
+
+export const GRAPH_NODE_WIDTH: number = 28;
+export const GRAPH_NODE_HEIGHT: number = 28;
+export const GRAPH_COL_WIDTH: number = 46;
+export const GRAPH_NODE_GAP: number = 10;
+export const GRAPH_LEVEL_GAP: number = 70;
+export const GRAPH_BASE_X: number = 16;
+export const GRAPH_BASE_Y: number = 16;
+
+export const graphState: IGraphState = {
+ nodePositions: new Map(),
+ nodeStatus: new Map(),
+ nodeElements: new Map(),
+ edgeElements: []
+};
+
+export interface IGraphViewControllerOptions {
+ graphEl: HTMLElement;
+ edgesSvg: SVGSVGElement;
+ getOperations: () => Map;
+ getExecutionStates: () => Map;
+ getQueuedStates: () => Map;
+ getSelection: () => Set;
+ getFilteredOutNames: () => Set;
+ getSearchFilteredOutNames: () => Set;
+ getLastExecutionResults: () => Map;
+ getComputeDisplayStatus: (op: IOperationInfo) => string;
+ getStatusEmoji: (status: string) => string;
+ getOverallStatusText: (status: string | undefined) => string;
+ renderPhaseLegend: () => void;
+ singleSelect: (name: string) => void;
+ toggleSelect: (name: string) => void;
+}
+
+export interface IGraphViewController {
+ markGraphDirty(): void;
+ ensureGraph(): void;
+ updateGraph(): void;
+}
+
+export function createGraphViewController(options: IGraphViewControllerOptions): IGraphViewController {
+ let graphNeedsFullRender: boolean = true;
+ function getStatusColors(): Record {
+ const cs: CSSStyleDeclaration = getComputedStyle(document.documentElement);
+ return {
+ Ready: cs.getPropertyValue('--status-ready').trim(),
+ Waiting: cs.getPropertyValue('--status-waiting').trim(),
+ Queued: cs.getPropertyValue('--status-queued').trim(),
+ Executing: cs.getPropertyValue('--status-executing')?.trim() || cs.getPropertyValue('--warn').trim(),
+ Success: cs.getPropertyValue('--status-success')?.trim() || cs.getPropertyValue('--success').trim(),
+ SuccessWithWarning: cs.getPropertyValue('--status-success-warning').trim(),
+ Skipped: cs.getPropertyValue('--status-skipped').trim(),
+ FromCache: cs.getPropertyValue('--status-from-cache').trim(),
+ Failure: cs.getPropertyValue('--status-failure')?.trim() || cs.getPropertyValue('--danger').trim(),
+ Blocked: cs.getPropertyValue('--status-blocked').trim(),
+ NoOp: cs.getPropertyValue('--status-noop').trim(),
+ Aborted: cs.getPropertyValue('--status-aborted').trim()
+ };
+ }
+
+ let statusColors: Record = getStatusColors();
+
+ const mo: MutationObserver = new MutationObserver(() => {
+ statusColors = getStatusColors();
+ });
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] });
+
+ const updateStatusColors = (): void => {
+ statusColors = getStatusColors();
+ };
+
+ function computeLevels(filteredOps: IOperationInfo[]): Map {
+ const indegree: Map = new Map();
+ const deps: Map = new Map();
+ filteredOps.forEach((op: IOperationInfo) => {
+ deps.set(op.name, op.dependencies || []);
+ indegree.set(op.name, (op.dependencies || []).length);
+ });
+
+ const queue: string[] = [];
+ indegree.forEach((v: number, k: string) => {
+ if (v === 0) queue.push(k);
+ });
+
+ const level: Map = new Map();
+ queue.forEach((k: string) => level.set(k, 0));
+
+ while (queue.length) {
+ const cur: string | undefined = queue.shift();
+ if (!cur) continue;
+ const curLevel: number = level.get(cur) || 0;
+ filteredOps.forEach((op: IOperationInfo) => {
+ if ((op.dependencies || []).includes(cur)) {
+ indegree.set(op.name, (indegree.get(op.name) || 0) - 1);
+ if (!level.has(op.name) || (level.get(op.name) || 0) < curLevel + 1) {
+ level.set(op.name, curLevel + 1);
+ }
+ if ((indegree.get(op.name) || 0) === 0) queue.push(op.name);
+ }
+ });
+ }
+
+ return level;
+ }
+
+ function computeGraphOperations(): IOperationInfo[] {
+ const filteredOutNames: Set = options.getFilteredOutNames();
+ const searchFilteredOutNames: Set = options.getSearchFilteredOutNames();
+ const visibleOperations: IOperationInfo[] = [];
+
+ for (const op of options.getOperations().values()) {
+ if (filteredOutNames.has(op.name)) continue;
+ if (searchFilteredOutNames.has(op.name)) continue;
+ visibleOperations.push(op);
+ }
+
+ return pruneGraphOperations(visibleOperations);
+ }
+
+ function dimColor(hex: string, amount: number = 0.55): string {
+ if (!hex || !/^#?[0-9a-fA-F]{6}$/.test(hex)) return hex || '#4b5563';
+ if (hex[0] === '#') hex = hex.slice(1);
+ const r: number = parseInt(hex.slice(0, 2), 16);
+ const g: number = parseInt(hex.slice(2, 4), 16);
+ const b: number = parseInt(hex.slice(4, 6), 16);
+ const br: number = 30;
+ const bg: number = 41;
+ const bb: number = 59;
+ const nr: number = Math.round(r * (1 - amount) + br * amount);
+ const ng: number = Math.round(g * (1 - amount) + bg * amount);
+ const nb: number = Math.round(b * (1 - amount) + bb * amount);
+ return (
+ '#' +
+ nr.toString(16).padStart(2, '0') +
+ ng.toString(16).padStart(2, '0') +
+ nb.toString(16).padStart(2, '0')
+ );
+ }
+
+ function buildGraph(): void {
+ graphState.nodePositions.clear();
+ graphState.nodeStatus.clear();
+ graphState.nodeElements.forEach((el) => el.remove());
+ graphState.nodeElements.clear();
+ graphState.edgeElements.forEach((e: IGraphEdgeRecord) => e.path.remove());
+ graphState.edgeElements.length = 0;
+
+ options.edgesSvg.innerHTML =
+ '' +
+ Object.entries(statusColors)
+ .map(
+ ([status, color]: [string, string]) =>
+ ``
+ )
+ .join('') +
+ '';
+
+ const filteredOpsArr: IOperationInfo[] = computeGraphOperations();
+ const level: Map = computeLevels(filteredOpsArr);
+ const groups: Record = {};
+ level.forEach((value: number, name: string) => (groups[value] ||= []).push(name));
+ const sortedLevels: number[] = Object.keys(groups)
+ .map(Number)
+ .sort((a, b) => a - b);
+ const maxLevel: number = sortedLevels.length ? Math.max(...sortedLevels) : 0;
+
+ const dependentsMap: Map> = new Map>();
+ filteredOpsArr.forEach((op: IOperationInfo) => {
+ (op.dependencies || []).forEach((dependencyName: string) => {
+ if (!options.getOperations().has(dependencyName)) return;
+ let setForDependency: Set | undefined = dependentsMap.get(dependencyName);
+ if (!setForDependency) {
+ setForDependency = new Set();
+ dependentsMap.set(dependencyName, setForDependency);
+ }
+ setForDependency.add(op.name);
+ });
+ });
+
+ const byNameFiltered: Map = new Map(
+ filteredOpsArr.map((op) => [op.name, op])
+ );
+ const memoCpl: Map = new Map();
+ function criticalPathLen(name: string): number {
+ const cached: number | undefined = memoCpl.get(name);
+ if (cached !== undefined) return cached;
+ const op: IOperationInfo | undefined = byNameFiltered.get(name);
+ if (!op) {
+ memoCpl.set(name, 0);
+ return 0;
+ }
+ const deps: string[] = Array.from(dependentsMap.get(name) || new Set());
+ if (!deps.length) {
+ memoCpl.set(name, 0);
+ return 0;
+ }
+ let best: number = 0;
+ for (const dependencyName of deps) {
+ best = Math.max(best, 1 + criticalPathLen(dependencyName));
+ }
+ memoCpl.set(name, best);
+ return best;
+ }
+
+ sortedLevels.forEach((levelValue: number) => {
+ const nodes: string[] = groups[levelValue] || [];
+ nodes.sort((a: string, b: string) => {
+ const cplA: number = criticalPathLen(a);
+ const cplB: number = criticalPathLen(b);
+ if (cplA !== cplB) return cplB - cplA;
+ const consA: number = (dependentsMap.get(a) || new Set()).size;
+ const consB: number = (dependentsMap.get(b) || new Set()).size;
+ if (consA !== consB) return consB - consA;
+ return a.localeCompare(b);
+ });
+
+ const levelIndexFromTop: number = maxLevel - levelValue;
+ nodes.forEach((name: string, index: number) => {
+ const op: IOperationInfo | undefined = options.getOperations().get(name);
+ if (!op) return;
+ const x: number = GRAPH_BASE_X + index * (GRAPH_COL_WIDTH + GRAPH_NODE_GAP);
+ const y: number = GRAPH_BASE_Y + levelIndexFromTop * GRAPH_LEVEL_GAP;
+ const div: HTMLDivElement = document.createElement('div');
+ div.className = 'op-node';
+ div.dataset.name = name;
+ div.style.transform = `translate(${x}px, ${y}px)`;
+ const emojiSpan: HTMLSpanElement = document.createElement('span');
+ emojiSpan.className = 'emoji';
+ emojiSpan.textContent = options.getStatusEmoji(options.getComputeDisplayStatus(op));
+ div.appendChild(emojiSpan);
+ const enabledSup: HTMLSpanElement = document.createElement('span');
+ enabledSup.className = 'enabled-indicator';
+ enabledSup.textContent = '';
+ div.appendChild(enabledSup);
+ div.addEventListener('click', (e) => {
+ e.stopPropagation();
+ if (e.metaKey || e.ctrlKey) options.toggleSelect(name);
+ else options.singleSelect(name);
+ });
+ options.graphEl.appendChild(div);
+ graphState.nodePositions.set(name, { x, y });
+ graphState.nodeElements.set(name, div);
+ });
+ });
+
+ const byName: Map = new Map(
+ filteredOpsArr.map((op) => [op.name, op])
+ );
+ const memoReach: Map> = new Map>();
+ function getReachable(name: string): Set {
+ const cached: Set | undefined = memoReach.get(name);
+ if (cached) return cached;
+ const op: IOperationInfo | undefined = byName.get(name);
+ const visited: Set = new Set();
+ if (op) {
+ const stack: string[] = [...(op.dependencies || [])];
+ while (stack.length) {
+ const dependencyName: string | undefined = stack.pop();
+ if (!dependencyName) continue;
+ if (visited.has(dependencyName)) continue;
+ visited.add(dependencyName);
+ const dependencyOp: IOperationInfo | undefined = byName.get(dependencyName);
+ if (dependencyOp) stack.push(...(dependencyOp.dependencies || []));
+ }
+ }
+ memoReach.set(name, visited);
+ return visited;
+ }
+
+ const edgeRecords: IGraphEdgeRecord[] = [];
+ for (const op of filteredOpsArr) {
+ const deps: string[] = op.dependencies || [];
+ for (const depName of deps) {
+ if (!byName.has(depName)) continue;
+ let redundant: boolean = false;
+ for (const intermediate of deps) {
+ if (intermediate === depName) continue;
+ if (!byName.has(intermediate)) continue;
+ if (getReachable(intermediate).has(depName)) {
+ redundant = true;
+ break;
+ }
+ }
+ if (redundant) continue;
+ const fromPos: IPoint | undefined = graphState.nodePositions.get(op.name);
+ const toPos: IPoint | undefined = graphState.nodePositions.get(depName);
+ if (!fromPos || !toPos) continue;
+ const path: SVGPathElement = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+ edgeRecords.push({ path, from: op.name, to: depName });
+ options.edgesSvg.appendChild(path);
+ }
+ }
+ graphState.edgeElements = edgeRecords;
+ updateGraph();
+
+ if (graphState.nodePositions.size) {
+ const maxX: number =
+ Math.max(...Array.from(graphState.nodePositions.values()).map((p) => p.x)) + GRAPH_NODE_WIDTH + 40;
+ const maxY: number =
+ Math.max(...Array.from(graphState.nodePositions.values()).map((p) => p.y)) +
+ GRAPH_LEVEL_GAP +
+ GRAPH_NODE_HEIGHT;
+ options.edgesSvg.setAttribute('width', String(maxX));
+ options.edgesSvg.setAttribute('height', String(maxY));
+ }
+ }
+
+ function updateGraph(): void {
+ updateStatusColors();
+ for (const [name, div] of graphState.nodeElements.entries()) {
+ const op: IOperationInfo | undefined = options.getOperations().get(name);
+ if (!op) continue;
+ const displayStatus: string = options.getComputeDisplayStatus(op);
+ const prevStatus: string | undefined = graphState.nodeStatus.get(name);
+ const state: IOperationExecutionState | undefined = options.getExecutionStates().get(name);
+ const runInThisIteration: boolean | undefined = state
+ ? state.runInThisIteration
+ : op.runInThisIteration;
+ const notRunning: boolean = runInThisIteration === false || !!op.noop;
+ const queuedState: IOperationExecutionState | undefined = options.getQueuedStates().get(name);
+ const isQueuedNext: boolean = !!(queuedState && queuedState.runInThisIteration === true);
+ const isFilteredOut: boolean = options.getFilteredOutNames().has(name);
+ const isSearchFiltered: boolean = options.getSearchFilteredOutNames().has(name);
+ const emojiSpan: Element | null = div.querySelector('.emoji');
+ if (emojiSpan && (prevStatus !== displayStatus || !emojiSpan.textContent)) {
+ emojiSpan.textContent = options.getStatusEmoji(displayStatus);
+ }
+ const enabledSpan: HTMLSpanElement | null = div.querySelector(
+ '.enabled-indicator'
+ ) as HTMLSpanElement | null;
+ if (enabledSpan) {
+ let indicator: string = '';
+ if (op.noop) {
+ indicator = 'βͺ';
+ enabledSpan.title = 'No-op operation';
+ } else {
+ switch (op.enabled) {
+ case 'never':
+ indicator = 'π΄';
+ enabledSpan.title = 'Disabled';
+ break;
+ case 'ignore-dependency-changes':
+ indicator = 'π‘';
+ enabledSpan.title = 'Ignores dependency changes';
+ break;
+ case 'affected':
+ default:
+ indicator = 'π’';
+ enabledSpan.title = 'Enabled';
+ break;
+ }
+ }
+ if (enabledSpan.textContent !== indicator) enabledSpan.textContent = indicator;
+ }
+ let baseColor: string = statusColors[displayStatus] || '#4b5563';
+ if (isSearchFiltered) baseColor = dimColor(baseColor, 0.72);
+ else if (isFilteredOut) baseColor = dimColor(baseColor, 0.6);
+ else if (notRunning) baseColor = dimColor(baseColor, 0.35);
+ div.style.borderColor = baseColor;
+ if (options.getSelection().has(name)) div.classList.add('selected');
+ else div.classList.remove('selected');
+ let activeSpan: HTMLSpanElement | null = div.querySelector(
+ '.active-indicator'
+ ) as HTMLSpanElement | null;
+ if (op.isActive) {
+ if (!activeSpan) {
+ activeSpan = document.createElement('span');
+ activeSpan.className = 'active-indicator';
+ activeSpan.textContent = 'β‘';
+ div.appendChild(activeSpan);
+ }
+ activeSpan.title = 'Active (in-memory state)';
+ } else if (activeSpan) {
+ activeSpan.remove();
+ }
+ let pendingSpan: HTMLSpanElement | null = div.querySelector(
+ '.pending-indicator'
+ ) as HTMLSpanElement | null;
+ if (isQueuedNext) {
+ if (!pendingSpan) {
+ pendingSpan = document.createElement('span');
+ pendingSpan.className = 'pending-indicator';
+ pendingSpan.textContent = 'π';
+ div.appendChild(pendingSpan);
+ }
+ pendingSpan.title = 'Pending changes (iteration queued)';
+ } else if (pendingSpan) {
+ pendingSpan.remove();
+ }
+ div.classList.remove('not-running', 'filtered-out', 'filtered-out-search', 'dashed', 'dotted');
+ if (isSearchFiltered) {
+ div.classList.add('filtered-out-search');
+ } else if (isFilteredOut) {
+ div.classList.add('filtered-out');
+ } else if (notRunning) {
+ div.classList.add('not-running');
+ }
+ div.title = `${op.name}\nLast Result: ${(options.getLastExecutionResults().get(name) || {}).status || displayStatus}\n${options.getOverallStatusText(displayStatus)}${op.isActive ? '\nHas in-memory state' : ''}`;
+ graphState.nodeStatus.set(name, displayStatus);
+ }
+
+ for (const rec of graphState.edgeElements) {
+ const fromPos: IPoint | undefined = graphState.nodePositions.get(rec.from);
+ const toPos: IPoint | undefined = graphState.nodePositions.get(rec.to);
+ if (!fromPos || !toPos) continue;
+ const startX: number = fromPos.x + GRAPH_NODE_WIDTH / 2;
+ const startY: number = fromPos.y + GRAPH_NODE_HEIGHT;
+ const endX: number = toPos.x + GRAPH_NODE_WIDTH / 2;
+ const endY: number = toPos.y;
+ const rowsApart: number = Math.max(1, Math.round((toPos.y - fromPos.y) / GRAPH_LEVEL_GAP));
+ const colStep: number = GRAPH_COL_WIDTH + GRAPH_NODE_GAP;
+ function quadratic(sx: number, sy: number, ex: number, ey: number): string {
+ const mx: number = (sx + ex) / 2;
+ const my: number = (sy + ey) / 2;
+ const baseOffset: number = (ey - sy) / 4;
+ return `Q ${sx} ${sy + baseOffset} ${mx} ${my} ${ex} ${my + baseOffset} ${ex} ${ey}`;
+ }
+ let d: string = '';
+ if (startX === endX && rowsApart === 1) {
+ d = `M ${startX} ${startY} L ${endX} ${endY}`;
+ } else if (rowsApart === 1) {
+ d = `M ${startX} ${startY} ` + quadratic(startX, startY, endX, endY);
+ } else {
+ const dir: number = Math.sign(endX - startX) || 1;
+ const halfColShift: number = colStep / 2;
+ const candidateX: number = startX + (endX - startX) * 0.5;
+ const deltaCols: number = Math.max(1, Math.round(Math.abs(endX - startX) / colStep));
+ let intermediateX: number = candidateX;
+ let tooClose: boolean = false;
+ for (let k: number = 0; k <= deltaCols; k++) {
+ const center: number = startX + dir * k * colStep;
+ if (Math.abs(candidateX - center) < GRAPH_NODE_WIDTH / 2 + 2) {
+ tooClose = true;
+ break;
+ }
+ }
+ if (tooClose) intermediateX = candidateX + dir * halfColShift;
+ const firstTargetY: number = fromPos.y + GRAPH_LEVEL_GAP;
+ const bottomOfRowAboveDest: number = toPos.y - GRAPH_LEVEL_GAP + GRAPH_NODE_HEIGHT;
+ const midY1: number = firstTargetY;
+ const midY2: number = bottomOfRowAboveDest;
+ d = `M ${startX} ${startY} ` + quadratic(startX, startY, intermediateX, midY1);
+ d += ` L ${intermediateX} ${midY2}`;
+ d += ' ' + quadratic(intermediateX, midY2, endX, endY);
+ }
+ rec.path.setAttribute('d', d);
+ const depStatus: string = graphState.nodeStatus.get(rec.to) || 'Ready';
+ rec.path.setAttribute('stroke', statusColors[depStatus] || '#4b5563');
+ rec.path.setAttribute('class', 'edge');
+ rec.path.setAttribute('marker-end', `url(#arrowhead-${depStatus})`);
+ const fromOp: IOperationInfo | undefined = options.getOperations().get(rec.from);
+ if (options.getSelection().has(rec.to) && options.getSelection().has(rec.from))
+ rec.path.classList.add('highlight');
+ else rec.path.classList.remove('highlight');
+ rec.path.classList.remove('dashed', 'dotted', 'filtered-out', 'not-running');
+ rec.path.classList.remove('filtered-out-search');
+ const fromState: IOperationExecutionState | undefined = fromOp
+ ? options.getExecutionStates().get(rec.from)
+ : undefined;
+ const fromRunInThisIteration: boolean | undefined = fromState
+ ? fromState.runInThisIteration
+ : fromOp?.runInThisIteration;
+ const fromNotRunning: boolean = !!(fromOp && (fromRunInThisIteration === false || fromOp.noop));
+ const edgeStatusFiltered: boolean =
+ options.getFilteredOutNames().has(rec.from) || options.getFilteredOutNames().has(rec.to);
+ const edgeSearchFiltered: boolean =
+ options.getSearchFilteredOutNames().has(rec.from) || options.getSearchFilteredOutNames().has(rec.to);
+ if (edgeSearchFiltered || edgeStatusFiltered || fromNotRunning) {
+ let strokeColor: string = statusColors[depStatus] || '#4b5563';
+ if (edgeSearchFiltered) {
+ strokeColor = dimColor(strokeColor, 0.78);
+ rec.path.style.opacity = '0.22';
+ } else if (edgeStatusFiltered) {
+ strokeColor = dimColor(strokeColor, 0.65);
+ rec.path.style.opacity = '0.3';
+ } else if (fromNotRunning) {
+ strokeColor = dimColor(strokeColor, 0.4);
+ rec.path.style.opacity = '0.42';
+ }
+ rec.path.setAttribute('stroke', strokeColor);
+ rec.path.setAttribute('marker-end', `url(#arrowhead-${depStatus})`);
+ } else {
+ rec.path.style.opacity = '';
+ const semImportant: boolean = depStatus === 'Executing' || depStatus === 'Failure';
+ if (!semImportant && !rec.path.classList.contains('highlight')) {
+ rec.path.classList.add('dim');
+ } else {
+ rec.path.classList.remove('dim');
+ }
+ }
+ }
+
+ options.renderPhaseLegend();
+ }
+
+ function markGraphDirty(): void {
+ graphNeedsFullRender = true;
+ }
+
+ function ensureGraph(): void {
+ if (graphNeedsFullRender) {
+ buildGraph();
+ graphNeedsFullRender = false;
+ } else {
+ updateGraph();
+ }
+ }
+
+ return {
+ markGraphDirty,
+ ensureGraph,
+ updateGraph
+ };
+}
diff --git a/apps/rush-serve-dashboard/src/modules/leftBar.ts b/apps/rush-serve-dashboard/src/modules/leftBar.ts
new file mode 100644
index 00000000000..1d7f1ae6098
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/modules/leftBar.ts
@@ -0,0 +1,96 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+export interface ILeftBarActionWiringOptions {
+ sendCommand: (cmd: {
+ command: string;
+ operationNames?: string[];
+ targetState?: string;
+ mode?: string;
+ }) => void;
+ getSelection: () => Set;
+ clearSelectionAndRender: () => void;
+ expandSelectionDependencies: () => void;
+ expandSelectionConsumers: () => void;
+}
+
+export function wireLeftBarActions(options: ILeftBarActionWiringOptions): void {
+ const {
+ sendCommand,
+ getSelection,
+ clearSelectionAndRender,
+ expandSelectionDependencies,
+ expandSelectionConsumers
+ } = options;
+
+ const invalidateBtn: HTMLElement | null = document.getElementById('invalidate-btn');
+ const closeRunnersBtn: HTMLElement | null = document.getElementById('close-runners-btn');
+ const clearSelectionBtn: HTMLElement | null = document.getElementById('clear-selection-btn');
+ const expandDepsBtn: HTMLElement | null = document.getElementById('expand-deps-btn');
+ const expandConsumersBtn: HTMLElement | null = document.getElementById('expand-consumers-btn');
+ const setEnabledDefaultBtn: HTMLElement | null = document.getElementById('set-enabled-default-btn');
+ const setEnabledIgnoreDepsBtn: HTMLElement | null = document.getElementById('set-enabled-ignore-deps-btn');
+ const setEnabledDisabledBtn: HTMLElement | null = document.getElementById('set-enabled-disabled-btn');
+ const selectionModeBtn: HTMLElement | null = document.getElementById('selection-mode-btn');
+
+ if (invalidateBtn) {
+ invalidateBtn.addEventListener('click', () => {
+ sendCommand({ command: 'invalidate', operationNames: Array.from(getSelection()) });
+ });
+ }
+
+ if (closeRunnersBtn) {
+ closeRunnersBtn.addEventListener('click', () => {
+ sendCommand({ command: 'close-runners', operationNames: Array.from(getSelection()) });
+ });
+ }
+
+ if (expandDepsBtn) {
+ expandDepsBtn.addEventListener('click', expandSelectionDependencies);
+ }
+
+ if (expandConsumersBtn) {
+ expandConsumersBtn.addEventListener('click', expandSelectionConsumers);
+ }
+
+ let selectionEnableMode: 'safe' | 'unsafe' = 'safe';
+ if (selectionModeBtn) {
+ selectionModeBtn.addEventListener('click', () => {
+ selectionEnableMode = selectionEnableMode === 'safe' ? 'unsafe' : 'safe';
+ selectionModeBtn.dataset.mode = selectionEnableMode;
+ selectionModeBtn.textContent = `Mode: ${selectionEnableMode[0].toUpperCase()}${selectionEnableMode.slice(1)}`;
+ selectionModeBtn.title =
+ selectionEnableMode === 'safe'
+ ? 'Currently Safe mode (dependency aware). Click to switch to Unsafe.'
+ : 'Currently Unsafe mode (direct mutation). Click to switch to Safe.';
+ });
+ }
+
+ const sendEnableState = (targetState: 'affected' | 'ignore-dependency-changes' | 'never'): void => {
+ const selection: Set = getSelection();
+ if (!selection.size) return;
+
+ sendCommand({
+ command: 'set-enabled-states',
+ operationNames: Array.from(selection),
+ targetState,
+ mode: selectionEnableMode
+ });
+ };
+
+ if (setEnabledDefaultBtn) {
+ setEnabledDefaultBtn.addEventListener('click', () => sendEnableState('affected'));
+ }
+
+ if (setEnabledIgnoreDepsBtn) {
+ setEnabledIgnoreDepsBtn.addEventListener('click', () => sendEnableState('ignore-dependency-changes'));
+ }
+
+ if (setEnabledDisabledBtn) {
+ setEnabledDisabledBtn.addEventListener('click', () => sendEnableState('never'));
+ }
+
+ if (clearSelectionBtn) {
+ clearSelectionBtn.addEventListener('click', clearSelectionAndRender);
+ }
+}
diff --git a/apps/rush-serve-dashboard/src/modules/mainBar.ts b/apps/rush-serve-dashboard/src/modules/mainBar.ts
new file mode 100644
index 00000000000..fd05d76114f
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/modules/mainBar.ts
@@ -0,0 +1,108 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+export interface IMainBarActionWiringOptions {
+ connect: () => void;
+ disconnect: () => void;
+ isConnected: () => boolean;
+ sendCommand: (cmd: { command: string; value?: boolean; parallelism?: number }) => void;
+ getGraphSettings: () =>
+ | { debugMode?: boolean; verbose?: boolean; pauseNextIteration?: boolean }
+ | undefined;
+ debugBtn: HTMLElement | undefined;
+ verboseBtn: HTMLElement | undefined;
+ parallelismInput: HTMLInputElement | undefined;
+ playPauseBtn: HTMLElement | undefined;
+ getOperationNames: () => string[];
+ setSelection: (next: Set) => void;
+ clearSelection: () => void;
+ hasSelection: () => boolean;
+ render: () => void;
+}
+
+export function wireMainBarActions(options: IMainBarActionWiringOptions): void {
+ const {
+ connect,
+ disconnect,
+ isConnected,
+ sendCommand,
+ getGraphSettings,
+ debugBtn,
+ verboseBtn,
+ parallelismInput,
+ playPauseBtn,
+ getOperationNames,
+ setSelection,
+ clearSelection,
+ hasSelection,
+ render
+ } = options;
+
+ const connectBtn: HTMLElement | undefined = document.getElementById('connect-btn') ?? undefined;
+ if (connectBtn) {
+ connectBtn.addEventListener('click', () => {
+ if (isConnected()) {
+ disconnect();
+ } else {
+ connect();
+ }
+ });
+ }
+
+ const executeBtn: HTMLElement | undefined = document.getElementById('execute-btn') ?? undefined;
+ if (executeBtn) {
+ executeBtn.addEventListener('click', () => sendCommand({ command: 'execute' }));
+ }
+
+ const abortBtn: HTMLElement | undefined = document.getElementById('abort-execution-btn') ?? undefined;
+ if (abortBtn) {
+ abortBtn.addEventListener('click', () => sendCommand({ command: 'abort-execution' }));
+ }
+
+ if (debugBtn) {
+ debugBtn.addEventListener('click', () => {
+ const newVal: boolean = !getGraphSettings()?.debugMode;
+ debugBtn.title = newVal ? 'Turn off debug logging' : 'Turn on debug logging';
+ sendCommand({ command: 'set-debug', value: newVal });
+ });
+ }
+
+ if (verboseBtn) {
+ verboseBtn.addEventListener('click', () => {
+ const newVal: boolean = !getGraphSettings()?.verbose;
+ verboseBtn.title = newVal ? 'Turn off verbose logging' : 'Turn on verbose logging';
+ sendCommand({ command: 'set-verbose', value: newVal });
+ });
+ }
+
+ if (parallelismInput) {
+ parallelismInput.addEventListener('change', () => {
+ const value: number = Number(parallelismInput.value) || 1;
+ sendCommand({ command: 'set-parallelism', parallelism: value });
+ });
+ }
+
+ if (playPauseBtn) {
+ playPauseBtn.addEventListener('click', () => {
+ const graphSettings:
+ | { debugMode?: boolean; verbose?: boolean; pauseNextIteration?: boolean }
+ | undefined = getGraphSettings();
+ if (!graphSettings) return;
+ const next: boolean = !!graphSettings.pauseNextIteration;
+ sendCommand({ command: 'set-pause-next-iteration', value: !next });
+ });
+ }
+
+ window.addEventListener('keydown', (e: KeyboardEvent) => {
+ if (e.key === 'a' && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault();
+ setSelection(new Set(getOperationNames()));
+ render();
+ }
+
+ if (e.key === 'Escape' && hasSelection()) {
+ clearSelection();
+ render();
+ }
+ });
+}
diff --git a/apps/rush-serve-dashboard/src/modules/phaseLegend.ts b/apps/rush-serve-dashboard/src/modules/phaseLegend.ts
new file mode 100644
index 00000000000..3fe500eb458
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/modules/phaseLegend.ts
@@ -0,0 +1,445 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+interface IPhaseLegendOperation {
+ name: string;
+ phaseName?: string;
+ logFileURLs?: {
+ text?: string;
+ error?: string;
+ jsonl?: string;
+ };
+}
+
+interface IPhaseProblemOperation {
+ op: IPhaseLegendOperation;
+ displayStatus: string;
+}
+
+interface ILegendElement extends HTMLElement {
+ _initialized?: boolean;
+}
+
+export interface IPhaseLegendControllerOptions {
+ phaseGroupsEl: Element | undefined;
+ legendEl: HTMLElement | undefined;
+ getOperations: () => Map;
+ getGraphVisibleNames: () => Set | undefined;
+ computeDisplayStatus: (op: IPhaseLegendOperation) => string;
+ statusEmoji: (status: string) => string;
+ overallStatusText: (status: string | undefined) => string;
+ getStatusColors: () => Record;
+}
+
+export interface IPhaseLegendController {
+ renderPhasePane: () => void;
+ renderLegend: () => void;
+ renderAll: () => void;
+}
+
+const phaseStatusPriority: string[] = [
+ 'Failure',
+ 'SuccessWithWarning',
+ 'Blocked',
+ 'Aborted',
+ 'Executing',
+ 'Queued',
+ 'Ready',
+ 'Waiting',
+ 'Success',
+ 'Skipped',
+ 'FromCache',
+ 'NoOp'
+];
+
+const phaseStatusPriorityIndex: Map = new Map(
+ phaseStatusPriority.map((status, index) => [status, index])
+);
+
+const legendOrder: string[] = [...phaseStatusPriority];
+
+export function createPhaseLegendController(options: IPhaseLegendControllerOptions): IPhaseLegendController {
+ const computePhaseSummaries = (): Array<{
+ phase: string;
+ status: string;
+ problemOps: IPhaseProblemOperation[];
+ }> => {
+ const byPhase: Map }> = new Map();
+ const graphMembership: Set | undefined = options.getGraphVisibleNames();
+
+ for (const op of options.getOperations().values()) {
+ const phase: string = op.phaseName || '(none)';
+ const displayStatus: string = options.computeDisplayStatus(op);
+
+ // Keep currently executing operations visible in phase summaries even when not in graph membership.
+ if (graphMembership && displayStatus !== 'Executing' && !graphMembership.has(op.name)) {
+ continue;
+ }
+
+ let rec: { ops: IPhaseProblemOperation[]; statusSet: Set } | undefined = byPhase.get(phase);
+ if (!rec) {
+ rec = { ops: [], statusSet: new Set() };
+ byPhase.set(phase, rec);
+ }
+
+ rec.ops.push({ op, displayStatus });
+ rec.statusSet.add(displayStatus);
+ }
+
+ const summaries: Array<{
+ phase: string;
+ status: string;
+ problemOps: IPhaseProblemOperation[];
+ }> = [];
+
+ for (const [phase, rec] of byPhase.entries()) {
+ let chosen: string | undefined;
+ let bestIdx: number = Infinity;
+
+ for (const status of rec.statusSet) {
+ const index: number | undefined = phaseStatusPriorityIndex.get(status);
+ if (index !== undefined && index < bestIdx) {
+ bestIdx = index;
+ chosen = status;
+ }
+ }
+
+ if (!chosen) chosen = 'Ready';
+
+ const problemOps: IPhaseProblemOperation[] = rec.ops.filter(
+ ({ displayStatus }) =>
+ displayStatus === 'Failure' ||
+ displayStatus === 'SuccessWithWarning' ||
+ displayStatus === 'Executing'
+ );
+
+ summaries.push({ phase, status: chosen, problemOps });
+ }
+
+ summaries.sort((a, b) => a.phase.localeCompare(b.phase));
+ return summaries;
+ };
+
+ const renderPhasePane = (): void => {
+ if (!options.phaseGroupsEl) return;
+
+ const summaries: Array<{ phase: string; status: string; problemOps: IPhaseProblemOperation[] }> =
+ computePhaseSummaries();
+ (options.phaseGroupsEl as HTMLElement).innerHTML = '';
+
+ if (!summaries.length) {
+ const empty: HTMLDivElement = document.createElement('div');
+ empty.className = 'phase-pane-empty';
+ empty.textContent = 'No phases';
+ options.phaseGroupsEl.appendChild(empty);
+ return;
+ }
+
+ for (const summary of summaries) {
+ const group: HTMLDivElement = document.createElement('div');
+ group.className = 'phase-group';
+
+ const header: HTMLDivElement = document.createElement('div');
+ header.className = 'phase-header';
+
+ const emoji: HTMLSpanElement = document.createElement('span');
+ emoji.className = 'phase-status-emoji';
+ emoji.textContent = options.statusEmoji(summary.status);
+
+ const nameSpan: HTMLSpanElement = document.createElement('span');
+ nameSpan.className = 'phase-name';
+ nameSpan.textContent = summary.phase.replace(/^_phase:/, '');
+
+ header.appendChild(emoji);
+ header.appendChild(nameSpan);
+ group.appendChild(header);
+
+ if (summary.problemOps.length) {
+ const list: HTMLUListElement = document.createElement('ul');
+ list.className = 'phase-problems';
+
+ const sortedProblems: IPhaseProblemOperation[] = [...summary.problemOps].sort((a, b) => {
+ const ai: number = phaseStatusPriorityIndex.get(a.displayStatus) ?? 999;
+ const bi: number = phaseStatusPriorityIndex.get(b.displayStatus) ?? 999;
+ if (ai !== bi) return ai - bi;
+ const an: string = a.op.name.toLowerCase();
+ const bn: string = b.op.name.toLowerCase();
+ if (an < bn) return -1;
+ if (an > bn) return 1;
+ return 0;
+ });
+
+ for (const { op, displayStatus } of sortedProblems) {
+ const item: HTMLLIElement = document.createElement('li');
+
+ const status: HTMLSpanElement = document.createElement('span');
+ status.className = 'phase-problem-emoji';
+ status.textContent = options.statusEmoji(displayStatus);
+ item.appendChild(status);
+
+ const logUrl: string | undefined =
+ (op.logFileURLs && (op.logFileURLs.text || op.logFileURLs.error || op.logFileURLs.jsonl)) ||
+ undefined;
+ if (logUrl) {
+ const link: HTMLAnchorElement = document.createElement('a');
+ link.href = logUrl;
+ link.target = '_blank';
+ link.rel = 'noopener noreferrer';
+ link.textContent = op.name;
+ item.appendChild(link);
+ } else {
+ const span: HTMLSpanElement = document.createElement('span');
+ span.textContent = op.name;
+ item.appendChild(span);
+ }
+
+ list.appendChild(item);
+ }
+
+ group.appendChild(list);
+ }
+
+ options.phaseGroupsEl.appendChild(group);
+ }
+ };
+
+ const renderLegend = (): void => {
+ const legendEl: ILegendElement | undefined = options.legendEl as ILegendElement | undefined;
+ if (!legendEl) return;
+
+ if (!legendEl._initialized) {
+ const header: HTMLHeadingElement = document.createElement('h4');
+ header.textContent = 'Legend';
+
+ const toggleBtn: HTMLButtonElement = document.createElement('button');
+ toggleBtn.type = 'button';
+ toggleBtn.id = 'legend-collapse-btn';
+ toggleBtn.setAttribute('aria-label', 'Collapse legend');
+ toggleBtn.style.background = 'transparent';
+ toggleBtn.style.border = 'none';
+ toggleBtn.style.color = 'var(--text)';
+ toggleBtn.style.cursor = 'pointer';
+ toggleBtn.style.fontSize = '12px';
+ toggleBtn.style.padding = '2px 4px';
+ toggleBtn.style.marginLeft = 'auto';
+ toggleBtn.style.display = 'flex';
+ toggleBtn.style.alignItems = 'center';
+ toggleBtn.style.lineHeight = '1';
+ toggleBtn.textContent = 'β';
+
+ header.appendChild(toggleBtn);
+ legendEl.appendChild(header);
+ legendEl._initialized = true;
+
+ const collapsed: boolean = window.localStorage.getItem('rushServeLegendCollapsed') === '1';
+ if (collapsed) legendEl.classList.add('collapsed');
+ toggleBtn.textContent = collapsed ? '+' : 'β';
+ toggleBtn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
+
+ toggleBtn.addEventListener('click', () => {
+ const isCollapsed: boolean = legendEl.classList.toggle('collapsed');
+ window.localStorage.setItem('rushServeLegendCollapsed', isCollapsed ? '1' : '0');
+ toggleBtn.textContent = isCollapsed ? '+' : 'β';
+ toggleBtn.setAttribute('aria-label', isCollapsed ? 'Expand legend' : 'Collapse legend');
+ toggleBtn.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');
+ renderLegend();
+ });
+ }
+
+ while (legendEl.children.length > 1) {
+ const lastChild: ChildNode | null = legendEl.lastChild;
+ if (!lastChild) break;
+ legendEl.removeChild(lastChild);
+ }
+
+ if (legendEl.classList.contains('collapsed')) {
+ const stub: HTMLDivElement = document.createElement('div');
+ stub.style.fontSize = '0.5rem';
+ stub.style.opacity = '0.7';
+ stub.textContent = 'Collapsed';
+ legendEl.appendChild(stub);
+ return;
+ }
+
+ const statusColors: Record = options.getStatusColors();
+
+ const columnsWrap: HTMLDivElement = document.createElement('div');
+ columnsWrap.className = 'legend-columns';
+
+ const colPrimary: HTMLDivElement = document.createElement('div');
+ colPrimary.className = 'legend-col';
+
+ const primaryHead: HTMLDivElement = document.createElement('div');
+ primaryHead.className = 'legend-heading';
+ primaryHead.textContent = 'Statuses';
+ colPrimary.appendChild(primaryHead);
+
+ for (const status of legendOrder) {
+ const row: HTMLDivElement = document.createElement('div');
+ row.className = 'legend-row';
+
+ const sample: HTMLSpanElement = document.createElement('span');
+ sample.className = 'legend-emoji';
+ sample.textContent = options.statusEmoji(status);
+ sample.style.borderColor = statusColors[status] || '#4b5563';
+
+ const labelWrap: HTMLDivElement = document.createElement('div');
+ labelWrap.className = 'legend-label-wrap';
+ const titleSpan: HTMLSpanElement = document.createElement('span');
+ titleSpan.textContent = options.overallStatusText(status);
+ labelWrap.appendChild(titleSpan);
+
+ row.appendChild(sample);
+ row.appendChild(labelWrap);
+ colPrimary.appendChild(row);
+ }
+
+ const unknownRow: HTMLDivElement = document.createElement('div');
+ unknownRow.className = 'legend-row';
+
+ const unknownSample: HTMLSpanElement = document.createElement('span');
+ unknownSample.className = 'legend-emoji status-Unknown';
+ unknownSample.textContent = 'β';
+ unknownSample.style.borderColor = '#4b5563';
+
+ const unknownLabelWrap: HTMLDivElement = document.createElement('div');
+ unknownLabelWrap.className = 'legend-label-wrap';
+
+ const unknownTitle: HTMLSpanElement = document.createElement('span');
+ unknownTitle.textContent = 'UNKNOWN';
+ const unknownDetail: HTMLElement = document.createElement('small');
+ unknownDetail.textContent = 'Never executed';
+
+ unknownLabelWrap.appendChild(unknownTitle);
+ unknownLabelWrap.appendChild(unknownDetail);
+
+ unknownRow.appendChild(unknownSample);
+ unknownRow.appendChild(unknownLabelWrap);
+ colPrimary.appendChild(unknownRow);
+
+ const colSecondary: HTMLDivElement = document.createElement('div');
+ colSecondary.className = 'legend-col';
+
+ const secondaryHead: HTMLDivElement = document.createElement('div');
+ secondaryHead.className = 'legend-heading';
+ secondaryHead.textContent = 'State Modifiers';
+ colSecondary.appendChild(secondaryHead);
+
+ const addModifier = (sampleFactory: () => HTMLElement, label: string, detail?: string): void => {
+ const row: HTMLDivElement = document.createElement('div');
+ row.className = 'legend-row';
+ const sample: HTMLElement = sampleFactory();
+
+ const labelWrap: HTMLDivElement = document.createElement('div');
+ labelWrap.className = 'legend-label-wrap';
+ const titleSpan: HTMLSpanElement = document.createElement('span');
+ titleSpan.textContent = label;
+ labelWrap.appendChild(titleSpan);
+
+ if (detail) {
+ const small: HTMLElement = document.createElement('small');
+ small.textContent = detail;
+ labelWrap.appendChild(small);
+ }
+
+ row.appendChild(sample);
+ row.appendChild(labelWrap);
+ colSecondary.appendChild(row);
+ };
+
+ const makeNodeBox = (borderStyle?: string, boxShadow?: string, borderColor?: string): HTMLElement => {
+ const sample: HTMLSpanElement = document.createElement('span');
+ sample.className = 'legend-emoji';
+ if (borderStyle) sample.style.borderStyle = borderStyle;
+ if (borderColor) sample.style.borderColor = borderColor;
+ if (boxShadow) sample.style.boxShadow = boxShadow;
+ sample.textContent = ' ';
+ return sample;
+ };
+
+ const makeDashed = (): HTMLElement => makeNodeBox('dashed');
+ const makeDotted = (): HTMLElement => makeNodeBox('dotted');
+
+ const makeActive = (): HTMLElement => {
+ const wrap: HTMLDivElement = document.createElement('div');
+ wrap.className = 'legend-enabled-sample';
+
+ const base: HTMLSpanElement = document.createElement('span');
+ base.style.opacity = '0.15';
+ base.style.fontSize = '11px';
+ base.textContent = 'β¬';
+ wrap.appendChild(base);
+
+ const rocket: HTMLSpanElement = document.createElement('span');
+ rocket.style.position = 'absolute';
+ rocket.style.bottom = '0';
+ rocket.style.left = '0';
+ rocket.style.transform = 'translate(-50%, 50%)';
+ rocket.style.fontSize = '12px';
+ rocket.textContent = 'β‘';
+ wrap.appendChild(rocket);
+
+ return wrap;
+ };
+
+ const makePending = (): HTMLElement => {
+ const wrap: HTMLDivElement = document.createElement('div');
+ wrap.className = 'legend-enabled-sample';
+
+ const base: HTMLSpanElement = document.createElement('span');
+ base.style.opacity = '0.15';
+ base.style.fontSize = '11px';
+ base.textContent = 'β¬';
+ wrap.appendChild(base);
+
+ const clock: HTMLSpanElement = document.createElement('span');
+ clock.style.position = 'absolute';
+ clock.style.top = '0';
+ clock.style.left = '0';
+ clock.style.transform = 'translate(-50%, -50%)';
+ clock.style.fontSize = '12px';
+ clock.textContent = 'π';
+ wrap.appendChild(clock);
+
+ return wrap;
+ };
+
+ const makeEnabledSample = (emoji: string): HTMLElement => {
+ const wrap: HTMLDivElement = document.createElement('div');
+ wrap.className = 'legend-enabled-sample';
+ const sub: HTMLSpanElement = document.createElement('span');
+ sub.className = 'sub';
+ sub.textContent = emoji;
+ wrap.appendChild(sub);
+ return wrap;
+ };
+
+ addModifier(makePending, 'Pending changes', 'Iteration queued');
+ addModifier(makeActive, 'Active', 'In-memory state');
+ addModifier(makeDashed, 'Not in this iteration', 'Excluded this iteration');
+ addModifier(makeDotted, 'Filtered out', 'Hidden by view/search');
+
+ const enabledHead: HTMLDivElement = document.createElement('div');
+ enabledHead.className = 'legend-subheading';
+ enabledHead.textContent = 'Enabled States';
+ colSecondary.appendChild(enabledHead);
+
+ addModifier(() => makeEnabledSample('π’'), 'Enabled', 'Runs normally');
+ addModifier(() => makeEnabledSample('π‘'), 'Ignore dependency changes', 'Skips if no local changes');
+ addModifier(() => makeEnabledSample('π΄'), 'Disabled', 'Never runs');
+ addModifier(() => makeEnabledSample('βͺ'), 'No-op', 'Operation does no work');
+
+ columnsWrap.appendChild(colPrimary);
+ columnsWrap.appendChild(colSecondary);
+ legendEl.appendChild(columnsWrap);
+ };
+
+ return {
+ renderPhasePane,
+ renderLegend,
+ renderAll: () => {
+ renderPhasePane();
+ renderLegend();
+ }
+ };
+}
diff --git a/apps/rush-serve-dashboard/src/modules/selectionBar.ts b/apps/rush-serve-dashboard/src/modules/selectionBar.ts
new file mode 100644
index 00000000000..3e6cd6f7cfb
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/modules/selectionBar.ts
@@ -0,0 +1,67 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+export interface ISelectionBarControllerOptions {
+ getSelection: () => Set;
+ getCurrentView: () => string;
+ isConnected: () => boolean;
+}
+
+export interface ISelectionBarController {
+ updateSelectionUI: () => void;
+}
+
+const selectionButtonsIds: string[] = [
+ 'invalidate-btn',
+ 'close-runners-btn',
+ 'set-enabled-default-btn',
+ 'set-enabled-ignore-deps-btn',
+ 'set-enabled-disabled-btn',
+ 'expand-deps-btn',
+ 'expand-consumers-btn'
+];
+
+export function createSelectionBarController(
+ options: ISelectionBarControllerOptions
+): ISelectionBarController {
+ const updateSelectionUI = (): void => {
+ const bar: HTMLElement | null = document.getElementById('selection-bar');
+ if (!bar) return;
+
+ bar.style.display = 'flex';
+ const headingSpan: HTMLElement | null = document.getElementById('view-heading-text');
+ if (headingSpan) {
+ headingSpan.textContent =
+ options.getCurrentView() === 'graph' ? 'Dependency Graph' : 'Operations Table';
+ }
+
+ const hasSelection: boolean = options.getSelection().size > 0;
+ const connected: boolean = options.isConnected();
+ selectionButtonsIds.forEach((id) => {
+ const el: HTMLButtonElement | HTMLInputElement | null = document.getElementById(id) as
+ | HTMLButtonElement
+ | HTMLInputElement
+ | null;
+ if (el) el.disabled = !(hasSelection && connected);
+ });
+
+ const clearBtn: HTMLButtonElement | null = document.getElementById(
+ 'clear-selection-btn'
+ ) as HTMLButtonElement | null;
+ if (clearBtn) {
+ clearBtn.disabled = !(hasSelection && connected);
+ clearBtn.title = 'Clear selection';
+ clearBtn.setAttribute('aria-label', 'Clear selection');
+ }
+
+ const countSpan: HTMLElement | null = document.getElementById('selection-count');
+ if (countSpan) {
+ const count: number = options.getSelection().size;
+ countSpan.textContent = count + (count === 1 ? ' selected' : ' selected');
+ }
+ };
+
+ return {
+ updateSelectionUI
+ };
+}
diff --git a/apps/rush-serve-dashboard/src/modules/statusHelpers.ts b/apps/rush-serve-dashboard/src/modules/statusHelpers.ts
new file mode 100644
index 00000000000..713f89526f1
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/modules/statusHelpers.ts
@@ -0,0 +1,109 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+interface IOperationExecutionStateLike {
+ name: string;
+ status?: string;
+ runInThisIteration?: boolean;
+}
+
+interface IOperationInfoLike {
+ name: string;
+ status?: string;
+ runInThisIteration?: boolean;
+ isActive?: boolean;
+ noop?: boolean;
+ enabled?: string;
+}
+
+const statusEmojiMap: Record = {
+ Ready: 'βΈοΈ',
+ Waiting: 'π',
+ Queued: 'π',
+ Executing: 'βοΈ',
+ Success: 'β
',
+ SuccessWithWarning: 'β οΈ',
+ Skipped: 'π€',
+ FromCache: 'π©',
+ Failure: 'β',
+ Blocked: 'π«',
+ NoOp: 'π€',
+ Aborted: 'π',
+ Disconnected: 'βΈοΈ',
+ Unknown: 'β'
+};
+
+export function statusEmoji(status: string): string {
+ return statusEmojiMap[status] || 'β’';
+}
+
+export function computeDisplayStatus(
+ op: IOperationInfoLike,
+ executionStates: Map,
+ lastExecutionResults: Map
+): string {
+ const state: IOperationExecutionStateLike | undefined = executionStates.get(op.name);
+ let displayStatus: string = state?.status || op.status || '';
+ const runInThisIteration: boolean | undefined = state ? state.runInThisIteration : op.runInThisIteration;
+
+ if (runInThisIteration === false) {
+ const prev: IOperationExecutionStateLike | undefined = lastExecutionResults.get(op.name);
+ displayStatus = prev?.status || 'Skipped';
+ }
+
+ if (!displayStatus) {
+ const last: IOperationExecutionStateLike | undefined = lastExecutionResults.get(op.name);
+ displayStatus = last?.status || (op.noop ? 'NoOp' : 'Ready');
+ }
+
+ return displayStatus;
+}
+
+export function enabledGlyph(op: IOperationInfoLike): string {
+ if (op.noop) return 'βͺ';
+
+ switch (op.enabled) {
+ case 'never':
+ return 'π΄';
+ case 'ignore-dependency-changes':
+ return 'π‘';
+ default:
+ return 'π’';
+ }
+}
+
+export function buildRunPolicyText(op: IOperationInfoLike): string {
+ if (op.noop) return 'Operation does no work';
+
+ switch (op.enabled) {
+ case 'never':
+ return 'Never run';
+ case 'ignore-dependency-changes':
+ return 'Ignores dependency changes';
+ default:
+ return 'Run if affected';
+ }
+}
+
+export function buildTooltip(op: IOperationInfoLike, lastResultStatus: string): string {
+ const activeLine: string = op.isActive ? '\nHas in-memory state' : '';
+ return `${op.name}\nLast Result: ${lastResultStatus}\n${buildRunPolicyText(op)}${activeLine}`;
+}
+
+export function getStatusColors(): Record {
+ const cs: CSSStyleDeclaration = getComputedStyle(document.documentElement);
+ return {
+ Ready: cs.getPropertyValue('--status-ready').trim(),
+ Waiting: cs.getPropertyValue('--status-waiting').trim(),
+ Queued: cs.getPropertyValue('--status-queued').trim(),
+ Executing: cs.getPropertyValue('--status-executing').trim() || cs.getPropertyValue('--warn').trim(),
+ Success: cs.getPropertyValue('--status-success').trim() || cs.getPropertyValue('--success').trim(),
+ SuccessWithWarning: cs.getPropertyValue('--status-success-warning').trim(),
+ Skipped: cs.getPropertyValue('--status-skipped').trim(),
+ FromCache: cs.getPropertyValue('--status-from-cache').trim(),
+ Failure: cs.getPropertyValue('--status-failure').trim() || cs.getPropertyValue('--danger').trim(),
+ Blocked: cs.getPropertyValue('--status-blocked').trim(),
+ NoOp: cs.getPropertyValue('--status-noop').trim(),
+ Aborted: cs.getPropertyValue('--status-aborted').trim()
+ };
+}
diff --git a/apps/rush-serve-dashboard/src/modules/tableView.ts b/apps/rush-serve-dashboard/src/modules/tableView.ts
new file mode 100644
index 00000000000..e7e440ba300
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/modules/tableView.ts
@@ -0,0 +1,270 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+interface ITableAnchorCoordinate {
+ row: number;
+ phase: number;
+}
+
+interface ITablePackageRecord {
+ packageName: string;
+ byPhase: Map;
+}
+
+interface ITableOperation {
+ name: string;
+ packageName?: string;
+ phaseName?: string;
+ isActive?: boolean;
+ enabled?: string;
+}
+
+export interface ITableViewControllerOptions {
+ tableHead: HTMLElement | undefined;
+ tableBody: HTMLElement | undefined;
+ tableStats: HTMLElement | undefined;
+ getOperations: () => Map;
+ getFilteredOperations: () => ITableOperation[];
+ getSelection: () => Set;
+ setSelection: (nextSelection: Set) => void;
+ onSelectionMutated: () => void;
+ computeDisplayStatus: (op: ITableOperation) => string;
+ enabledGlyph: (op: ITableOperation) => string;
+ buildRunPolicyText: (op: ITableOperation) => string;
+ buildTooltip: (op: ITableOperation, lastResultStatus: string) => string;
+ statusEmoji: (status: string) => string;
+ overallStatusText: (status: string | undefined) => string;
+}
+
+export interface ITableViewController {
+ renderTable: () => void;
+}
+
+export function createTableViewController(options: ITableViewControllerOptions): ITableViewController {
+ let tableOpOrder: string[] = [];
+ let lastTableAnchorName: string | undefined;
+ let lastTableAnchorCoord: ITableAnchorCoordinate | undefined;
+ let lastTablePhases: string[] = [];
+ let lastTablePackages: ITablePackageRecord[] = [];
+
+ const buildPivotData = (): { phases: string[]; packages: ITablePackageRecord[] } => {
+ const allPhases: Set = new Set();
+ for (const op of options.getOperations().values()) {
+ const phaseName: string = op.phaseName || '(none)';
+ allPhases.add(phaseName);
+ }
+ const phases: string[] = Array.from(allPhases).sort();
+
+ const filteredOps: ITableOperation[] = options.getFilteredOperations();
+ const packageMap: Map = new Map();
+ for (const op of filteredOps) {
+ const packageName: string = op.packageName || '(unknown package)';
+ const phaseName: string = op.phaseName || '(none)';
+ let rec: ITablePackageRecord | undefined = packageMap.get(packageName);
+ if (!rec) {
+ rec = { packageName, byPhase: new Map() };
+ packageMap.set(packageName, rec);
+ }
+ rec.byPhase.set(phaseName, op);
+ }
+
+ const packages: ITablePackageRecord[] = Array.from(packageMap.values()).sort((a, b) =>
+ a.packageName.localeCompare(b.packageName)
+ );
+
+ return { phases, packages };
+ };
+
+ const commitSelection = (nextSelection: Set): void => {
+ options.setSelection(nextSelection);
+ options.onSelectionMutated();
+ };
+
+ const handleMultiSelectGroup = (e: MouseEvent, names: string[]): void => {
+ const isMeta: boolean = e.metaKey || e.ctrlKey;
+ const isShift: boolean = e.shiftKey;
+ let nextSelection: Set = new Set(options.getSelection());
+
+ if (isShift && lastTableAnchorName && tableOpOrder.includes(lastTableAnchorName)) {
+ if (!isMeta) nextSelection = new Set(nextSelection);
+ names.forEach((name) => nextSelection.add(name));
+ } else if (isMeta) {
+ let anyNew: boolean = false;
+ names.forEach((name) => {
+ if (!nextSelection.delete(name)) {
+ nextSelection.add(name);
+ anyNew = true;
+ }
+ });
+ if (anyNew && names.length) lastTableAnchorName = names[0];
+ } else {
+ nextSelection = new Set(names);
+ if (names.length) lastTableAnchorName = names[0];
+ }
+
+ commitSelection(nextSelection);
+ };
+
+ const handlePivotCellClick = (
+ e: MouseEvent,
+ opName: string,
+ rowIndex: number,
+ phaseIndex: number
+ ): void => {
+ if (!opName) return;
+
+ const isMeta: boolean = e.metaKey || e.ctrlKey;
+ const isShift: boolean = e.shiftKey;
+
+ if (isShift && lastTableAnchorCoord) {
+ const { row: anchorRow, phase: anchorPhase } = lastTableAnchorCoord;
+ const rowStart: number = Math.min(anchorRow, rowIndex);
+ const rowEnd: number = Math.max(anchorRow, rowIndex);
+ const phaseStart: number = Math.min(anchorPhase, phaseIndex);
+ const phaseEnd: number = Math.max(anchorPhase, phaseIndex);
+ const rectNames: Set = new Set();
+
+ for (let row: number = rowStart; row <= rowEnd; row++) {
+ const packageRecord: ITablePackageRecord | undefined = lastTablePackages[row];
+ if (!packageRecord) continue;
+
+ for (let phase: number = phaseStart; phase <= phaseEnd; phase++) {
+ const phaseName: string | undefined = lastTablePhases[phase];
+ if (!phaseName) continue;
+ const cellOp: ITableOperation | undefined = packageRecord.byPhase.get(phaseName);
+ if (cellOp) rectNames.add(cellOp.name);
+ }
+ }
+
+ if (isMeta) {
+ const nextSelection: Set = new Set(options.getSelection());
+ rectNames.forEach((name) => nextSelection.add(name));
+ commitSelection(nextSelection);
+ } else {
+ commitSelection(rectNames);
+ }
+
+ return;
+ }
+
+ const nextSelection: Set = new Set(options.getSelection());
+ if (isMeta) {
+ if (nextSelection.has(opName)) nextSelection.delete(opName);
+ else nextSelection.add(opName);
+ } else {
+ nextSelection.clear();
+ nextSelection.add(opName);
+ }
+
+ lastTableAnchorName = opName;
+ lastTableAnchorCoord = { row: rowIndex, phase: phaseIndex };
+ commitSelection(nextSelection);
+ };
+
+ const renderTable = (): void => {
+ const tableHead: HTMLElement | undefined = options.tableHead;
+ const tableBody: HTMLElement | undefined = options.tableBody;
+ const tableStats: HTMLElement | undefined = options.tableStats;
+ if (!tableHead || !tableBody || !tableStats) return;
+
+ const { phases, packages } = buildPivotData();
+ tableOpOrder = [];
+ lastTablePhases = phases;
+ lastTablePackages = packages;
+
+ tableHead.innerHTML = '';
+ const headerRow: HTMLTableRowElement = document.createElement('tr');
+ const packageHeader: HTMLTableCellElement = document.createElement('th');
+ packageHeader.textContent = 'Package';
+ headerRow.appendChild(packageHeader);
+
+ for (const phase of phases) {
+ const th: HTMLTableCellElement = document.createElement('th');
+ const displayPhase: string = phase.replace(/^_phase:/, '');
+ th.textContent = displayPhase;
+ if (displayPhase !== phase) th.title = phase;
+ th.className = 'phase-col-header';
+ th.style.cursor = 'pointer';
+ th.addEventListener('click', (e: Event) => {
+ const phaseNames: string[] = [];
+ for (const op of options.getOperations().values()) {
+ if ((op.phaseName || '(none)') === phase) phaseNames.push(op.name);
+ }
+ handleMultiSelectGroup(e as MouseEvent, phaseNames);
+ });
+ headerRow.appendChild(th);
+ }
+ tableHead.appendChild(headerRow);
+
+ tableBody.innerHTML = '';
+ let opCount: number = 0;
+ const selection: Set = options.getSelection();
+
+ packages.forEach((pkg, rowIndex) => {
+ const tr: HTMLTableRowElement = document.createElement('tr');
+ tr.className = 'pkg-row';
+
+ const namesInRow: string[] = Array.from(pkg.byPhase.values()).map((op) => op.name);
+ const allSelected: boolean = !!namesInRow.length && namesInRow.every((name) => selection.has(name));
+ if (allSelected) tr.classList.add('selected');
+
+ const pkgTd: HTMLTableCellElement = document.createElement('td');
+ pkgTd.className = 'pkg-cell';
+ pkgTd.textContent = pkg.packageName;
+ pkgTd.style.fontWeight = '600';
+ pkgTd.style.cursor = 'pointer';
+ pkgTd.addEventListener('click', (e: Event) => {
+ handleMultiSelectGroup(e as MouseEvent, namesInRow);
+ e.stopPropagation();
+ });
+ tr.appendChild(pkgTd);
+
+ phases.forEach((phase, phaseIndex) => {
+ const td: HTMLTableCellElement = document.createElement('td');
+ td.className = 'pivot-cell';
+ td.style.whiteSpace = 'nowrap';
+
+ const op: ITableOperation | undefined = pkg.byPhase.get(phase);
+ if (op) {
+ opCount++;
+ const displayStatus: string = options.computeDisplayStatus(op);
+ const glyph: string = options.enabledGlyph(op);
+ const active: string = op.isActive ? 'β‘' : '';
+ td.innerHTML = `
+ ${options.statusEmoji(displayStatus)}
+ ${escapeHtml(options.overallStatusText(displayStatus))}
+ ${glyph}
+ ${active}
+ `;
+ td.title = options.buildTooltip(op, displayStatus);
+ if (selection.has(op.name)) td.classList.add('selected');
+ td.style.cursor = 'pointer';
+ tableOpOrder.push(op.name);
+ td.addEventListener('click', (e: Event) => {
+ handlePivotCellClick(e as MouseEvent, op.name, rowIndex, phaseIndex);
+ e.stopPropagation();
+ });
+ } else {
+ td.innerHTML = 'β';
+ }
+
+ tr.appendChild(td);
+ });
+
+ tableBody.appendChild(tr);
+ });
+
+ tableStats.textContent = opCount + ' operations';
+ };
+
+ return {
+ renderTable
+ };
+}
+
+function escapeHtml(s: string): string {
+ return String(s).replace(
+ /[&<>"']/g,
+ (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] as string
+ );
+}
diff --git a/apps/rush-serve-dashboard/src/modules/terminalPane.ts b/apps/rush-serve-dashboard/src/modules/terminalPane.ts
new file mode 100644
index 00000000000..6e127791822
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/modules/terminalPane.ts
@@ -0,0 +1,206 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+import { AnsiSgrParser } from './ansiSgrParser';
+
+interface ITerminalElementWithState extends HTMLElement {
+ _savedWidth?: number;
+}
+
+export interface ITerminalPaneRefs {
+ terminalEl: HTMLElement | undefined;
+ terminalBody: HTMLElement | undefined;
+ termClearBtn: HTMLElement | undefined;
+ termAutoScrollCheckbox: HTMLInputElement | undefined;
+ termAutoscrollBtn: HTMLElement | undefined;
+ toggleTerminalBtn: HTMLElement | undefined;
+ resizerEl: HTMLElement | undefined;
+}
+
+export interface ITerminalPaneController {
+ appendChunk(kind: string | undefined, text: string | undefined): void;
+}
+
+export function createTerminalPaneController(refs: ITerminalPaneRefs): ITerminalPaneController {
+ const ansiParser: AnsiSgrParser = new AnsiSgrParser();
+ _wireLayoutInit(refs.terminalEl);
+ _wireClearButton(refs.terminalBody, refs.termClearBtn);
+ _wireResizer(refs.terminalEl, refs.resizerEl);
+ _wireToggle(refs);
+
+ return {
+ appendChunk(kind: string | undefined, text: string | undefined): void {
+ _appendChunk(refs, ansiParser, kind, text);
+ }
+ };
+}
+
+function _appendChunk(
+ refs: ITerminalPaneRefs,
+ ansiParser: AnsiSgrParser,
+ kind: string | undefined,
+ text: string | undefined
+): void {
+ const { terminalEl, terminalBody, termAutoScrollCheckbox } = refs;
+ if (!terminalBody) return;
+
+ const raw: string = String(text || '');
+ const segments: Array<{ text: string; style?: string }> = ansiParser.process(raw);
+
+ if (segments && segments.length) {
+ for (const seg of segments) {
+ const span: HTMLSpanElement = document.createElement('span');
+ span.className = 'term-chunk ' + (kind === 'stderr' ? 'stderr' : 'stdout');
+ if (seg.style) span.setAttribute('style', seg.style);
+ span.textContent = seg.text;
+ terminalBody.appendChild(span);
+ }
+ }
+
+ if (!termAutoScrollCheckbox || termAutoScrollCheckbox.checked) {
+ terminalBody.scrollTop = terminalBody.scrollHeight;
+ }
+
+ if (terminalEl && !terminalEl.classList.contains('hidden')) {
+ terminalEl.classList.add('term-flash');
+ setTimeout(() => terminalEl.classList.remove('term-flash'), 350);
+ }
+}
+
+function _wireLayoutInit(terminalEl: HTMLElement | undefined): void {
+ try {
+ if (terminalEl) {
+ terminalEl.style.top = '';
+ }
+ } catch {
+ // no-op
+ }
+}
+
+function _wireClearButton(
+ terminalBody: HTMLElement | undefined,
+ termClearBtn: HTMLElement | undefined
+): void {
+ if (!terminalBody || !termClearBtn) return;
+
+ termClearBtn.addEventListener('click', () => {
+ terminalBody.innerHTML = '';
+ });
+}
+
+function _wireResizer(terminalEl: HTMLElement | undefined, resizerEl: HTMLElement | undefined): void {
+ if (!terminalEl || !resizerEl) return;
+
+ const terminalWithState: ITerminalElementWithState = terminalEl as ITerminalElementWithState;
+ let dragging: boolean = false;
+ let startX: number = 0;
+ let startWidth: number = 0;
+ const minW: number = 120;
+ const maxW: number = Math.max(240, window.innerWidth - 200);
+
+ resizerEl.addEventListener('pointerdown', (e: PointerEvent) => {
+ dragging = true;
+ startX = e.clientX;
+ startWidth = terminalWithState.getBoundingClientRect().width;
+ resizerEl.setPointerCapture(e.pointerId);
+ document.body.style.userSelect = 'none';
+ });
+
+ window.addEventListener('pointermove', (e: PointerEvent) => {
+ if (!dragging) return;
+ const dx: number = startX - e.clientX;
+ let newW: number = startWidth + dx;
+ newW = Math.max(minW, Math.min(maxW, newW));
+
+ terminalWithState._savedWidth = newW;
+ if (!terminalWithState.classList.contains('hidden')) {
+ terminalWithState.style.width = newW + 'px';
+ terminalWithState.style.flex = '0 0 ' + newW + 'px';
+ }
+ });
+
+ window.addEventListener('pointerup', (e: PointerEvent) => {
+ if (!dragging) return;
+ dragging = false;
+ try {
+ resizerEl.releasePointerCapture(e.pointerId);
+ } catch {
+ // ignore mismatched pointer state
+ }
+ document.body.style.userSelect = '';
+ });
+
+ resizerEl.tabIndex = 0;
+ resizerEl.addEventListener('keydown', (e: KeyboardEvent) => {
+ const step: number = 16;
+ const rect: DOMRect = terminalWithState.getBoundingClientRect();
+ let w: number = rect.width;
+ if (e.key === 'ArrowLeft') w = Math.max(minW, w - step);
+ else if (e.key === 'ArrowRight') w = Math.min(maxW, w + step);
+
+ terminalWithState._savedWidth = w;
+ if (!terminalWithState.classList.contains('hidden')) {
+ terminalWithState.style.width = w + 'px';
+ terminalWithState.style.flex = '0 0 ' + w + 'px';
+ }
+ });
+}
+
+function _wireToggle(refs: ITerminalPaneRefs): void {
+ const { toggleTerminalBtn, terminalEl, resizerEl, termAutoscrollBtn, termAutoScrollCheckbox } = refs;
+ if (!toggleTerminalBtn || !terminalEl) return;
+
+ const terminalWithState: ITerminalElementWithState = terminalEl as ITerminalElementWithState;
+
+ toggleTerminalBtn.addEventListener('click', () => {
+ const currentlyHidden: boolean = terminalWithState.classList.contains('hidden');
+
+ if (currentlyHidden) {
+ terminalWithState.classList.remove('hidden');
+ if (resizerEl) resizerEl.classList.remove('hidden');
+
+ if (terminalWithState._savedWidth) {
+ terminalWithState.style.width = terminalWithState._savedWidth + 'px';
+ terminalWithState.style.flex = '0 0 ' + terminalWithState._savedWidth + 'px';
+ } else {
+ terminalWithState.style.width = '';
+ terminalWithState.style.flex = '';
+ }
+
+ if (resizerEl) resizerEl.tabIndex = 0;
+ toggleTerminalBtn.setAttribute('aria-pressed', 'true');
+ toggleTerminalBtn.classList.add('active');
+ } else {
+ try {
+ terminalWithState._savedWidth = terminalWithState.getBoundingClientRect().width;
+ } catch {
+ // no-op
+ }
+
+ terminalWithState.classList.add('hidden');
+ if (resizerEl) {
+ resizerEl.classList.add('hidden');
+ resizerEl.tabIndex = -1;
+ }
+
+ terminalWithState.style.width = '';
+ terminalWithState.style.flex = '';
+ toggleTerminalBtn.setAttribute('aria-pressed', 'false');
+ toggleTerminalBtn.classList.remove('active');
+ }
+ });
+
+ const isVisible: boolean = !terminalWithState.classList.contains('hidden');
+ toggleTerminalBtn.setAttribute('aria-pressed', isVisible ? 'true' : 'false');
+ if (isVisible) toggleTerminalBtn.classList.add('active');
+ else toggleTerminalBtn.classList.remove('active');
+
+ if (termAutoscrollBtn && termAutoScrollCheckbox) {
+ termAutoscrollBtn.setAttribute('aria-pressed', termAutoScrollCheckbox.checked ? 'true' : 'false');
+ termAutoscrollBtn.addEventListener('click', () => {
+ const newVal: boolean = !termAutoScrollCheckbox.checked;
+ termAutoScrollCheckbox.checked = newVal;
+ termAutoscrollBtn.setAttribute('aria-pressed', newVal ? 'true' : 'false');
+ });
+ }
+}
diff --git a/apps/rush-serve-dashboard/src/modules/topBar.ts b/apps/rush-serve-dashboard/src/modules/topBar.ts
new file mode 100644
index 00000000000..f7f7fd6a950
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/modules/topBar.ts
@@ -0,0 +1,190 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+export interface ITopBarRefs {
+ connectBtn: HTMLElement | undefined;
+ statusPill: HTMLElement | undefined;
+ statusEmojiEl: HTMLElement | undefined;
+ debugBtn: HTMLElement | undefined;
+ verboseBtn: HTMLElement | undefined;
+ playPauseBtn: HTMLElement | undefined;
+ parallelismInput: HTMLInputElement | undefined;
+ managerStateEl: HTMLElement | undefined;
+}
+
+export interface ITopBarGraphState {
+ status?: string;
+ debugMode?: boolean;
+ verbose?: boolean;
+ pauseNextIteration?: boolean;
+ parallelism?: number | string;
+ hasScheduledIteration?: boolean;
+}
+
+export function overallStatusText(status: string | undefined): string {
+ if (!status) return '';
+
+ switch (status) {
+ case 'SuccessWithWarning':
+ return 'WARNING';
+ case 'FromCache':
+ return 'CACHED';
+ case 'NoOp':
+ return 'NO-OP';
+ case 'Disconnected':
+ return 'DISCONNECTED';
+ case 'Connecting':
+ return 'CONNECTING';
+ case 'Connected':
+ return 'CONNECTED';
+ case 'Unknown':
+ return 'UNKNOWN';
+ default:
+ return String(status).toUpperCase();
+ }
+}
+
+export function computeWsUrl(loc: Location): string {
+ if (!loc || !loc.host) {
+ return 'ws://localhost:9001/';
+ }
+
+ const proto: string = loc.protocol === 'https:' ? 'wss:' : 'ws:';
+ return proto + '//' + loc.host + '/ws';
+}
+
+export function updateDerivedUrlDisplay(connectBtn: HTMLElement | undefined): void {
+ if (!connectBtn) return;
+
+ const url: string = computeWsUrl(window.location);
+ connectBtn.title = 'Connect to WebSocket at ' + url;
+ connectBtn.setAttribute('aria-label', 'Connect to WebSocket at ' + url);
+}
+
+export function showConnectingStatus(
+ statusPill: HTMLElement | undefined,
+ statusEmojiEl: HTMLElement | undefined,
+ statusEmoji: (status: string) => string
+): void {
+ if (!statusPill || !statusEmojiEl) return;
+
+ statusPill.className = 'status-pill status-Unspecified';
+ statusEmojiEl.textContent = statusEmoji('Waiting');
+ statusPill.textContent = overallStatusText('Connecting');
+}
+
+export function updateStatusPill(
+ refs: ITopBarRefs,
+ ws: WebSocket | undefined,
+ graphSettings: ITopBarGraphState | undefined,
+ statusEmoji: (status: string) => string
+): void {
+ if (!refs.statusPill || !refs.statusEmojiEl) return;
+
+ let pillStatus: string = 'Disconnected';
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ pillStatus = graphSettings?.status || 'Unspecified';
+ }
+
+ refs.statusPill.className = '';
+ refs.statusPill.classList.add('status-pill', 'status-' + pillStatus);
+ refs.statusEmojiEl.textContent = statusEmoji(pillStatus);
+ refs.statusPill.textContent = overallStatusText(pillStatus);
+}
+
+export function setConnected(
+ refs: ITopBarRefs,
+ connected: boolean,
+ updateSelectionUI: () => void,
+ disabledControlIds: string[]
+): void {
+ const connectBtnEl: HTMLElement | undefined = refs.connectBtn;
+ const iconSpan: HTMLElement | undefined =
+ (connectBtnEl?.querySelector('span.codicon') as HTMLElement | null) ?? undefined;
+
+ if (connectBtnEl) {
+ if (connected) {
+ if (iconSpan) iconSpan.className = 'codicon codicon-debug-disconnect';
+ connectBtnEl.setAttribute('data-state', 'connected');
+ connectBtnEl.title = 'Disconnect WebSocket';
+ connectBtnEl.setAttribute('aria-label', 'Disconnect WebSocket');
+ } else {
+ if (iconSpan) iconSpan.className = 'codicon codicon-plug';
+ connectBtnEl.setAttribute('data-state', 'disconnected');
+ connectBtnEl.title = 'Connect to WebSocket';
+ connectBtnEl.setAttribute('aria-label', 'Connect to WebSocket');
+ updateDerivedUrlDisplay(connectBtnEl);
+ }
+ }
+
+ disabledControlIds.forEach((id) => {
+ const el: HTMLButtonElement | HTMLInputElement | undefined =
+ (document.getElementById(id) as HTMLButtonElement | HTMLInputElement | null) ?? undefined;
+ if (el) el.disabled = !connected;
+ });
+
+ updateSelectionUI();
+}
+
+export function updateManagerState(refs: ITopBarRefs, graphSettings: ITopBarGraphState): void {
+ if (!graphSettings) return;
+
+ const { debugBtn, verboseBtn, playPauseBtn, parallelismInput, managerStateEl } = refs;
+
+ if (debugBtn) {
+ if (graphSettings.debugMode) debugBtn.classList.add('active');
+ else debugBtn.classList.remove('active');
+ debugBtn.setAttribute('aria-pressed', graphSettings.debugMode ? 'true' : 'false');
+ debugBtn.title = graphSettings.debugMode ? 'Turn off debug logging' : 'Turn on debug logging';
+ }
+
+ if (verboseBtn) {
+ if (graphSettings.verbose) verboseBtn.classList.add('active');
+ else verboseBtn.classList.remove('active');
+ verboseBtn.setAttribute('aria-pressed', graphSettings.verbose ? 'true' : 'false');
+ verboseBtn.title = graphSettings.verbose ? 'Turn off verbose logging' : 'Turn on verbose logging';
+ }
+
+ const ppIcon: HTMLElement | undefined =
+ (playPauseBtn?.querySelector('.codicon') as HTMLElement | null) ?? undefined;
+ if (playPauseBtn) {
+ if (!graphSettings.pauseNextIteration) {
+ playPauseBtn.classList.add('playing');
+ playPauseBtn.setAttribute('aria-label', 'Switch to manual (pause)');
+ playPauseBtn.title = 'Pause automatic iterations';
+ if (ppIcon) {
+ ppIcon.classList.remove('codicon-debug-start', 'codicon-debug-continue');
+ ppIcon.classList.add('codicon-debug-pause');
+ }
+ } else {
+ playPauseBtn.classList.remove('playing');
+ playPauseBtn.setAttribute('aria-label', 'Switch to automatic (play)');
+ playPauseBtn.title = 'Resume automatic iterations';
+ if (ppIcon) {
+ ppIcon.classList.remove('codicon-debug-pause');
+ ppIcon.classList.add('codicon-debug-start');
+ }
+ }
+ }
+
+ if (parallelismInput) {
+ parallelismInput.value = String(graphSettings.parallelism ?? '');
+ }
+
+ if (managerStateEl) {
+ managerStateEl.innerHTML = '';
+ }
+
+ const executeBtn: HTMLElement | undefined = document.getElementById('execute-btn') ?? undefined;
+ if (executeBtn) {
+ if (graphSettings.hasScheduledIteration) {
+ executeBtn.classList.add('queued');
+ executeBtn.title = 'Run once (changes detected)';
+ executeBtn.setAttribute('aria-label', 'Run once (changes detected)');
+ } else {
+ executeBtn.classList.remove('queued');
+ executeBtn.title = 'Run once';
+ executeBtn.setAttribute('aria-label', 'Run once');
+ }
+ }
+}
diff --git a/apps/rush-serve-dashboard/src/modules/urlState.ts b/apps/rush-serve-dashboard/src/modules/urlState.ts
new file mode 100644
index 00000000000..94748819a56
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/modules/urlState.ts
@@ -0,0 +1,47 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+export type DashboardView = 'table' | 'graph';
+export type DashboardFilter = 'all' | 'failed-warn';
+
+export interface IDashboardUrlState {
+ view: DashboardView;
+ filter: DashboardFilter;
+}
+
+export function loadDashboardUrlState(search: string): IDashboardUrlState {
+ const state: IDashboardUrlState = {
+ view: 'table',
+ filter: 'all'
+ };
+
+ try {
+ const params: URLSearchParams = new URLSearchParams(search);
+ const viewParam: string | null = params.get('view');
+ const filterParam: string | null = params.get('filter');
+
+ if (viewParam === 'graph' || viewParam === 'table') {
+ state.view = viewParam;
+ }
+
+ if (filterParam === 'failed-warn' || filterParam === 'all') {
+ state.filter = filterParam;
+ }
+ } catch {
+ // ignore invalid URL state
+ }
+
+ return state;
+}
+
+export function syncDashboardUrlState(view: DashboardView, filter: DashboardFilter): void {
+ try {
+ const params: URLSearchParams = new URLSearchParams(window.location.search);
+ params.set('view', view);
+ params.set('filter', filter);
+ const newUrl: string = window.location.pathname + '?' + params.toString() + window.location.hash;
+ window.history.replaceState(null, '', newUrl);
+ } catch {
+ // ignore browser APIs unavailable
+ }
+}
diff --git a/apps/rush-serve-dashboard/src/modules/viewBar.ts b/apps/rush-serve-dashboard/src/modules/viewBar.ts
new file mode 100644
index 00000000000..c574bb70a26
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/modules/viewBar.ts
@@ -0,0 +1,82 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+import { syncDashboardUrlState, type DashboardFilter, type DashboardView } from './urlState';
+
+export interface IViewBarWiringOptions {
+ getView: () => DashboardView;
+ setView: (next: DashboardView) => void;
+ getFilter: () => DashboardFilter;
+ setFilter: (next: DashboardFilter) => void;
+ setSearchQuery: (next: string) => void;
+ markGraphDirty: () => void;
+ render: () => void;
+}
+
+export function wireViewBar(options: IViewBarWiringOptions): void {
+ const { getView, setView, getFilter, setFilter, setSearchQuery, markGraphDirty, render } = options;
+
+ document.querySelectorAll('input[name="view"]').forEach((radio: Element) => {
+ radio.addEventListener('change', () => {
+ const input: HTMLInputElement = radio as HTMLInputElement;
+ if (!input.checked) return;
+
+ setView(input.value as DashboardView);
+ _applyViewVisibility(getView());
+ syncDashboardUrlState(getView(), getFilter());
+ render();
+ });
+ });
+
+ const filterSelect: HTMLSelectElement | null = document.getElementById(
+ 'filter-select'
+ ) as HTMLSelectElement | null;
+ if (filterSelect) {
+ filterSelect.addEventListener('change', (e: Event) => {
+ const next: DashboardFilter = (e.target as HTMLSelectElement).value as DashboardFilter;
+ setFilter(next);
+ markGraphDirty();
+ syncDashboardUrlState(getView(), getFilter());
+ render();
+ });
+ }
+
+ const nameSearchInput: HTMLInputElement | null = document.getElementById(
+ 'name-search'
+ ) as HTMLInputElement | null;
+ if (nameSearchInput) {
+ nameSearchInput.addEventListener('input', () => {
+ setSearchQuery(nameSearchInput.value);
+ markGraphDirty();
+ render();
+ });
+ }
+
+ const initialView: DashboardView = getView();
+ const viewRadio: HTMLInputElement | null = document.querySelector(
+ `input[name="view"][value="${initialView}"]`
+ ) as HTMLInputElement | null;
+ if (viewRadio) {
+ viewRadio.checked = true;
+ }
+
+ if (filterSelect) {
+ filterSelect.value = getFilter();
+ }
+
+ _applyViewVisibility(initialView);
+ syncDashboardUrlState(getView(), getFilter());
+}
+
+function _applyViewVisibility(view: DashboardView): void {
+ const leftPane: HTMLElement | null = document.getElementById('left');
+ const rightPane: HTMLElement | null = document.getElementById('right');
+
+ if (leftPane) {
+ leftPane.style.display = view === 'table' ? '' : 'none';
+ }
+
+ if (rightPane) {
+ rightPane.style.display = view === 'graph' ? '' : 'none';
+ }
+}
diff --git a/apps/rush-serve-dashboard/src/styles/dashboard.css b/apps/rush-serve-dashboard/src/styles/dashboard.css
new file mode 100644
index 00000000000..bfcc9201b1b
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/styles/dashboard.css
@@ -0,0 +1,553 @@
+/* Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. */
+/* See LICENSE in the project root for license information. */
+
+:root {
+ --bg: #0f1115;
+ --panel: #1b1f27;
+ --panel-alt: #232a34;
+ --border: #2a3240;
+ --text: #d4d7dd;
+ --muted: #8692a2;
+ --accent: #3b82f6;
+ --accent-hover: #2563eb;
+ --danger: #ef4444;
+ --warn: #f59e0b;
+ --success: #10b981;
+ --rgb-accent: 59, 130, 246;
+ --rgb-accent-hover: 37, 99, 235;
+ --rgb-success: 16, 185, 129;
+ --rgb-warn: 245, 158, 11;
+ --rgb-danger: 239, 68, 68;
+ --rgb-white: 255, 255, 255;
+ --rgb-panel-grad-a: 30, 37, 48;
+ --rgb-panel-grad-b: 16, 19, 24;
+ --status-ready: #334155;
+ --status-waiting: #475569;
+ --status-queued: #475569;
+ --status-executing: var(--warn);
+ --status-success: var(--success);
+ --status-success-warning: #eab308;
+ --status-skipped: #475569;
+ --status-from-cache: #0ea5e9;
+ --status-failure: var(--danger);
+ --status-blocked: #475569;
+ --status-noop: #6366f1;
+ --status-aborted: #64748b;
+ --status-disconnected: #334155;
+ --scroll-track: #1b1f27;
+ --scroll-thumb: #334155;
+ --scroll-thumb-hover: #3f5166;
+ --radius: 6px;
+ font-family:
+ system-ui,
+ Segoe UI,
+ Roboto,
+ Helvetica,
+ Arial,
+ sans-serif;
+}
+
+html,
+body {
+ height: 100%;
+ margin: 0;
+ background: var(--bg);
+ color: var(--text);
+}
+
+body {
+ display: flex;
+ flex-direction: column;
+}
+
+h1 {
+ font-size: 1.2rem;
+ margin: 0 0 0.5rem;
+}
+
+a {
+ color: var(--accent);
+}
+
+code {
+ background: var(--panel-alt);
+ padding: 2px 4px;
+ border-radius: 4px;
+}
+
+.flex-spacer {
+ flex: 1 1 auto;
+}
+
+#app-title {
+ font-size: 0.8rem;
+ font-weight: 600;
+ letter-spacing: 0.5px;
+ min-width: 180px;
+}
+
+#actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ align-items: center;
+}
+
+#graph-state {
+ display: flex;
+ gap: 0.4rem;
+ flex-wrap: wrap;
+}
+
+.parallelism-label {
+ font-size: 0.65rem;
+ align-items: baseline;
+ gap: 4px;
+}
+
+.search-label {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+#parallelism-input,
+#name-search {
+ background: var(--panel-alt);
+ border: 1px solid var(--border);
+ color: var(--text);
+ border-radius: var(--radius);
+ padding: 2px 4px;
+ font-size: 0.7rem;
+}
+
+#parallelism-input {
+ width: 60px;
+}
+
+#name-search {
+ min-width: 140px;
+}
+
+#connection-form {
+ gap: 0.4rem;
+}
+
+.content-wrap {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+}
+
+#main {
+ flex: 1;
+ display: flex;
+ min-height: 0;
+}
+
+#view-controls {
+ display: flex;
+ gap: 0.75rem;
+ align-items: center;
+ font-size: 0.7rem;
+}
+
+#view-controls label {
+ display: flex;
+ gap: 0.25rem;
+ align-items: center;
+ cursor: pointer;
+}
+
+select,
+#filter-select {
+ background: var(--panel-alt);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 2px 4px;
+ font-size: 0.7rem;
+}
+
+select:disabled {
+ opacity: 0.6;
+}
+
+#connection-form input {
+ background: var(--panel-alt);
+ border: 1px solid var(--border);
+ color: var(--text);
+ padding: 0.35rem 0.5rem;
+ border-radius: var(--radius);
+}
+
+#connection-form button.action {
+ padding: 0.4rem 0.75rem;
+ border-radius: var(--radius);
+ font-weight: 600;
+ cursor: pointer;
+}
+
+#connection-form button.icon-btn {
+ background: transparent;
+ border: none;
+ color: inherit;
+ padding: 0;
+}
+
+button:not(:disabled) {
+ cursor: pointer;
+}
+
+button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+button.action {
+ background: var(--panel-alt);
+ color: var(--text);
+ border: 1px solid var(--border);
+ padding: 0.35rem 0.6rem;
+ border-radius: var(--radius);
+ font-size: 0.8rem;
+}
+
+button.action:hover:not(:disabled) {
+ background: #2d3744;
+}
+
+button.primary,
+.icon-btn.primary {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: #fff;
+}
+
+button.primary:hover:not(:disabled),
+.icon-btn.primary:hover:not(:disabled) {
+ background: var(--accent-hover);
+}
+
+.icon-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 30px;
+ height: 30px;
+ padding: 0;
+ font-size: 16px;
+ line-height: 1;
+ background: transparent;
+ border: none;
+ color: var(--success);
+ position: relative;
+}
+
+.icon-btn:hover:not(:disabled) {
+ filter: brightness(1.15);
+}
+
+.icon-btn.stop {
+ color: var(--danger);
+}
+
+.icon-btn.stop:hover:not(:disabled) {
+ filter: brightness(1.15);
+}
+
+.icon-btn.playing {
+ color: var(--accent);
+}
+
+.icon-btn.playing:hover:not(:disabled) {
+ color: var(--accent-hover);
+ filter: none;
+}
+
+.icon-btn:disabled {
+ opacity: 0.45;
+}
+
+.icon-btn:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ border-radius: 4px;
+}
+
+.icon-btn.queued::after {
+ content: '';
+ position: absolute;
+ width: 18px;
+ height: 18px;
+ border: 2px dashed var(--warn);
+ border-radius: 50%;
+ animation: queuedPulse 1.2s linear infinite;
+ pointer-events: none;
+}
+
+.icon-btn.toggle {
+ color: var(--muted);
+}
+
+.icon-btn.toggle:hover:not(.active):not(:disabled) {
+ color: var(--text);
+}
+
+.icon-btn.toggle.active {
+ color: var(--accent);
+}
+
+.icon-btn.toggle.active:hover:not(:disabled) {
+ color: var(--accent-hover);
+}
+
+@keyframes queuedPulse {
+ 0% {
+ opacity: 0.2;
+ }
+ 50% {
+ opacity: 0.9;
+ }
+ 100% {
+ opacity: 0.2;
+ }
+}
+
+.codicon {
+ font-size: 18px;
+ line-height: 1;
+}
+
+.icon-btn .codicon {
+ pointer-events: none;
+}
+
+.icon-btn.stop .codicon {
+ font-size: 17px;
+}
+
+.icon-btn.queued .icon {
+ transform: scale(1.05);
+}
+
+button.danger {
+ background: var(--danger);
+ border-color: var(--danger);
+ color: #fff;
+}
+
+button.danger:hover:not(:disabled) {
+ filter: brightness(1.1);
+}
+
+#left,
+#right {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+#left {
+ border-right: 1px solid var(--border);
+}
+
+.section {
+ padding: 0.75rem 0.75rem 0.25rem;
+ background: var(--panel);
+ border-bottom: 1px solid var(--border);
+}
+
+.section h2 {
+ margin: 0 0 0.5rem;
+ font-size: 0.95rem;
+ letter-spacing: 0.5px;
+ font-weight: 600;
+}
+
+.status-pill {
+ padding: 2px 6px;
+ border-radius: 8px;
+ font-weight: 600;
+ font-size: 0.65rem;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ display: inline-block;
+ width: 110px;
+ text-align: center;
+ box-sizing: border-box;
+}
+
+.status-Ready {
+ background: var(--status-ready);
+}
+
+.status-Waiting {
+ background: var(--status-waiting);
+}
+
+.status-Queued {
+ background: var(--status-queued);
+}
+
+.status-Executing {
+ background: var(--warn);
+ color: #000;
+}
+
+.status-Success {
+ background: var(--success);
+ color: #000;
+}
+
+.status-SuccessWithWarning {
+ background: var(--status-success-warning);
+ color: #000;
+}
+
+.status-FromCache {
+ background: var(--status-from-cache);
+ color: #000;
+}
+
+.status-Failure {
+ background: var(--danger);
+}
+
+.status-Blocked,
+.status-Skipped {
+ background: var(--status-skipped);
+}
+
+.status-NoOp {
+ background: var(--status-noop);
+}
+
+.status-Aborted {
+ background: var(--status-aborted);
+}
+
+.status-Canceled {
+ background: var(--status-aborted);
+}
+
+.status-Unspecified {
+ background: var(--status-ready);
+}
+
+.status-Unknown {
+ background: var(--status-ready);
+ opacity: 0.85;
+}
+
+.status-Disconnected {
+ background: var(--status-disconnected);
+}
+
+.mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
+}
+
+.badge {
+ background: #334155;
+ padding: 2px 4px;
+ font-size: 0.55rem;
+ border-radius: 4px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ font-weight: 600;
+}
+
+.badge.silent {
+ background: #4b5563;
+}
+
+.badge.active {
+ background: var(--accent);
+}
+
+.badge.disabled {
+ background: #64748b;
+}
+
+.badge.local-only {
+ background: var(--warn);
+ color: #000;
+}
+
+.badge.not-running {
+ background: #1e293b;
+ border: 1px dashed #475569;
+}
+
+#bottom-bar {
+ background: var(--panel);
+ border-top: 1px solid var(--border);
+ padding: 0.3rem 0.6rem;
+ display: flex;
+ gap: 1rem;
+ font-size: 0.6rem;
+ align-items: center;
+}
+
+.pill {
+ background: var(--panel-alt);
+ padding: 2px 6px;
+ border-radius: 12px;
+}
+
+.flex-row {
+ display: flex;
+ gap: 0.4rem;
+ align-items: center;
+}
+
+input[type='number'] {
+ width: 70px;
+}
+
+.icon-btn.active,
+.icon-btn.toggle.active {
+ color: var(--accent);
+}
+
+@media (max-width: 1200px) {
+ #main {
+ flex-direction: column;
+ }
+
+ #left {
+ border-right: none;
+ border-bottom: 1px solid var(--border);
+ }
+}
+
+* {
+ scrollbar-color: var(--scroll-thumb) var(--scroll-track);
+ scrollbar-width: thin;
+}
+
+::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--scroll-track);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--scroll-thumb);
+ border: 2px solid var(--scroll-track);
+ border-radius: 8px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--scroll-thumb-hover);
+}
+
+::-webkit-scrollbar-corner {
+ background: var(--scroll-track);
+}
diff --git a/apps/rush-serve-dashboard/src/styles/graph-view.css b/apps/rush-serve-dashboard/src/styles/graph-view.css
new file mode 100644
index 00000000000..693f0796556
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/styles/graph-view.css
@@ -0,0 +1,449 @@
+/* Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. */
+/* See LICENSE in the project root for license information. */
+
+#graph-container {
+ position: relative;
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: row;
+ align-items: stretch;
+ gap: 0;
+}
+
+#graph {
+ width: 100%;
+ height: 100%;
+ position: relative;
+ overflow: auto;
+ background: radial-gradient(circle at 25% 20%, #1e2530, #101318);
+}
+
+#phase-pane {
+ width: 220px;
+ background: var(--panel);
+ border-right: 1px solid var(--border);
+ overflow: hidden;
+ font-size: 0.65rem;
+ padding: 0.4rem 0.5rem 0.6rem;
+ box-sizing: border-box;
+ position: relative;
+}
+
+#phase-pane h3 {
+ margin: 0 0 0.4rem;
+ font-size: 0.68rem;
+ letter-spacing: 0.5px;
+ text-transform: uppercase;
+ opacity: 0.8;
+}
+
+.phase-group {
+ padding: 0.4rem 0.45rem 0.45rem;
+ background: var(--panel-alt);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+#phase-pane .phase-groups {
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+ height: calc(100% - 1.2rem);
+ overflow: hidden;
+}
+
+#phase-pane .phase-groups > .phase-group {
+ flex: 1 1 0;
+}
+
+.phase-header {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ font-weight: 600;
+ margin-bottom: 0.3rem;
+ font-size: 0.66rem;
+ padding: 2px 4px 3px;
+ background: #27303c;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+}
+
+.phase-status-emoji {
+ font-size: 0.8rem;
+ line-height: 1;
+}
+
+.phase-name {
+ flex: 1;
+}
+
+.phase-problems {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ overflow-y: auto;
+ flex: 1 1 auto;
+ min-height: 0;
+}
+
+.phase-problems li {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+.phase-problems a {
+ color: var(--accent);
+ text-decoration: none;
+}
+
+.phase-problems a:hover {
+ text-decoration: underline;
+}
+
+.phase-problem-emoji {
+ font-size: 0.75rem;
+}
+
+.phase-pane-empty {
+ opacity: 0.6;
+ font-style: italic;
+ font-size: 0.6rem;
+}
+
+#graph-wrapper {
+ flex: 1;
+ min-height: 0;
+ position: relative;
+}
+
+#graph-legend {
+ position: absolute;
+ right: 28px;
+ bottom: 28px;
+ background: rgba(15, 17, 21, 0.9);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 4px 6px 6px;
+ font-size: 0.54rem;
+ line-height: 1.1;
+ max-width: 420px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
+ pointer-events: auto;
+}
+
+#graph-legend .legend-columns {
+ display: flex;
+ gap: 10px;
+ align-items: flex-start;
+}
+
+#graph-legend .legend-col:first-child {
+ flex: 0 0 130px;
+}
+
+#graph-legend .legend-col:last-child {
+ flex: 1 1 auto;
+}
+
+#graph-legend.collapsed {
+ width: auto;
+ max-width: none;
+ padding: 4px 6px;
+}
+
+#graph-legend.collapsed .legend-columns {
+ display: none;
+}
+
+#graph-legend .legend-col {
+ flex: 1 1 0;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+#graph-legend .legend-heading {
+ font-weight: 600;
+ letter-spacing: 0.5px;
+ text-transform: uppercase;
+ opacity: 0.75;
+ font-size: 0.58rem;
+ margin: 0 0 4px;
+}
+
+#graph-legend .legend-row {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ min-width: 0;
+}
+
+#graph-legend .legend-label-wrap {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+#graph-legend .legend-row small {
+ font-size: 0.5rem;
+ opacity: 0.7;
+}
+
+#graph-legend .legend-subheading {
+ margin: 4px 0 2px;
+ font-size: 0.52rem;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ opacity: 0.65;
+ font-weight: 600;
+}
+
+#graph-legend h4 {
+ margin: 0 0 4px;
+ font-size: 0.6rem;
+ letter-spacing: 0.6px;
+ text-transform: uppercase;
+ opacity: 0.8;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.legend-emoji {
+ font-size: 0.7rem;
+ width: 18px;
+ height: 18px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 2px solid var(--border);
+ border-radius: 6px;
+ background: #1f2530;
+ box-sizing: border-box;
+}
+
+.legend-enabled-sample {
+ position: relative;
+ width: 18px;
+ height: 18px;
+ border: 2px solid var(--border);
+ border-radius: 6px;
+ background: #1f2530;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 13px;
+ box-sizing: border-box;
+}
+
+.legend-enabled-sample .sub {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ transform: translate(50%, 50%);
+ font-size: 9px;
+ line-height: 1;
+ filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.55));
+ pointer-events: none;
+}
+
+.legend-label {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+#graph svg {
+ position: absolute;
+ top: 0;
+ left: 0;
+ overflow: visible;
+ pointer-events: none;
+}
+
+.graph-marquee {
+ position: absolute;
+ border: 1px solid var(--accent);
+ background: rgba(var(--rgb-accent), 0.15);
+ pointer-events: none;
+ z-index: 5;
+ box-shadow: 0 0 0 1px rgba(var(--rgb-accent), 0.4) inset;
+}
+
+.op-node {
+ position: absolute;
+ width: 28px;
+ height: 28px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ line-height: 1;
+ border: 2px solid var(--border);
+ border-radius: 6px;
+ background: #1f2530;
+ box-sizing: border-box;
+ user-select: none;
+ cursor: pointer;
+ transition:
+ border-color 0.15s ease,
+ transform 0.05s ease;
+}
+
+.op-node .enabled-indicator {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ transform: translate(50%, 50%);
+ font-size: 11px;
+ line-height: 1;
+ pointer-events: none;
+ filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.55));
+ transition: opacity 0.15s ease;
+}
+
+.op-node.selected {
+ box-shadow: 0 0 0 2px var(--accent);
+}
+
+.op-node:hover {
+ transform: scale(1.08);
+}
+
+.op-node .active-indicator {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ transform: translate(-50%, 50%);
+ font-size: 12px;
+ line-height: 1;
+ pointer-events: none;
+ filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.55));
+}
+
+.op-node .pending-indicator {
+ position: absolute;
+ top: 0;
+ left: 0;
+ transform: translate(-50%, -50%);
+ font-size: 12px;
+ line-height: 1;
+ pointer-events: none;
+ filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.55));
+}
+
+.op-node.filtered-out .active-indicator,
+.op-node.filtered-out-search .active-indicator,
+.op-node.not-running .active-indicator,
+.op-node.filtered-out.not-running .active-indicator {
+ opacity: 1 !important;
+}
+
+.op-node .name {
+ font-weight: 600;
+}
+
+.op-node .pkg {
+ color: var(--muted);
+ font-size: 0.6rem;
+}
+
+.op-node .badges {
+ display: flex;
+ gap: 4px;
+ flex-wrap: wrap;
+}
+
+.op-node .enabled-indicator {
+ opacity: 1;
+}
+
+.op-node.not-running .enabled-indicator {
+ opacity: 0.5;
+}
+
+.op-node.filtered-out .enabled-indicator {
+ opacity: 0.25;
+}
+
+.op-node.filtered-out-search .enabled-indicator {
+ opacity: 0.15;
+}
+
+.op-node.filtered-out.not-running .enabled-indicator {
+ opacity: 0.25;
+}
+
+.op-node .emoji {
+ transition: opacity 0.15s ease;
+}
+
+.op-node.filtered-out .emoji {
+ opacity: 0.25;
+}
+
+.op-node.filtered-out-search .emoji {
+ opacity: 0.15;
+}
+
+.op-node.not-running .emoji {
+ opacity: 0.5;
+}
+
+.op-node.filtered-out.not-running .emoji {
+ opacity: 0.25;
+}
+
+.op-node.dashed {
+ border-style: dashed;
+}
+
+.op-node.dotted {
+ border-style: dotted;
+}
+
+.edge {
+ stroke-width: 1.2;
+ fill: none;
+}
+
+.edge.dashed {
+ stroke-dasharray: 5 4;
+}
+
+.edge.dotted {
+ stroke-dasharray: 2 4;
+}
+
+.edge.filtered-out {
+ opacity: 0.35;
+}
+
+.edge.filtered-out-search {
+ opacity: 0.2;
+}
+
+.edge.highlight {
+ stroke: var(--accent);
+ stroke-width: 2;
+}
+
+.edge.dim {
+ opacity: 0.28;
+ filter: brightness(0.8);
+}
+
+.edge.not-running {
+ opacity: 0.55;
+}
diff --git a/apps/rush-serve-dashboard/src/styles/selection-bar.css b/apps/rush-serve-dashboard/src/styles/selection-bar.css
new file mode 100644
index 00000000000..71253cf0914
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/styles/selection-bar.css
@@ -0,0 +1,56 @@
+/* Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. */
+/* See LICENSE in the project root for license information. */
+
+#selection-heading {
+ font-weight: 600;
+ letter-spacing: 0.5px;
+ font-size: 0.7rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+#selection-actions {
+ display: flex;
+ gap: 0.4rem;
+ flex-wrap: wrap;
+ align-items: center;
+}
+
+/* Selection bar (hidden when no selection) */
+#selection-bar {
+ background: var(--panel-alt);
+ border-bottom: 1px solid var(--border);
+ padding: 0.35rem 0.6rem;
+ font-size: 0.65rem;
+ display: flex;
+ gap: 0.6rem;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+#selection-actions button.action {
+ font-size: 0.65rem;
+}
+
+#selection-bar .action {
+ line-height: 1.1;
+}
+
+#selection-bar button.action,
+#selection-bar .selection-count-btn {
+ height: 30px;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 0.65rem;
+}
+
+#selection-bar .selection-count-btn[disabled] {
+ opacity: 0.6;
+ cursor: default;
+}
+
+#selection-bar[aria-hidden='true'] {
+ display: none;
+}
diff --git a/apps/rush-serve-dashboard/src/styles/table-view.css b/apps/rush-serve-dashboard/src/styles/table-view.css
new file mode 100644
index 00000000000..462bdd370b3
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/styles/table-view.css
@@ -0,0 +1,108 @@
+/* Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. */
+/* See LICENSE in the project root for license information. */
+
+#operations-table-container {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.75rem;
+}
+
+thead {
+ position: sticky;
+ top: 0;
+ background: var(--panel-alt);
+ z-index: 2;
+}
+
+th,
+td {
+ padding: 0.35rem 0.5rem;
+ border-bottom: 1px solid var(--border);
+ text-align: left;
+ vertical-align: top;
+}
+
+tbody tr {
+ cursor: pointer;
+}
+
+tbody tr.selected {
+ background: rgba(var(--rgb-accent), 0.15);
+}
+
+tbody tr:hover {
+ background: rgba(var(--rgb-white), 0.05);
+}
+
+td.pivot-cell.selected {
+ background: rgba(var(--rgb-accent), 0.22);
+}
+
+tr.selected > td.pkg-cell {
+ background: rgba(var(--rgb-accent), 0.18);
+}
+
+/* Prevent brief native text highlight flash during selection interactions */
+td.pivot-cell,
+td.pkg-cell {
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ user-select: none;
+}
+
+th.sortable {
+ user-select: none;
+}
+
+th.sortable span.sort-indicator {
+ opacity: 0.5;
+ margin-left: 4px;
+}
+
+.status-row {
+ display: flex;
+ gap: 4px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.status-row .badges {
+ display: flex;
+ gap: 4px;
+ flex-wrap: wrap;
+ margin-left: 4px;
+}
+
+td.status-cell {
+ white-space: nowrap;
+}
+
+td.status-cell .status-pill {
+ margin-left: 4px;
+}
+
+.pivot-enabled {
+ margin-left: 4px;
+ font-size: 0.75rem;
+}
+
+.pivot-active {
+ margin-left: 4px;
+ font-size: 0.75rem;
+ position: relative;
+ top: 1px;
+}
+
+#table-stats {
+ font-size: 0.6rem;
+ color: var(--muted);
+ padding: 0.25rem 0.75rem 0.5rem;
+ background: var(--panel);
+ border-top: 1px solid var(--border);
+}
diff --git a/apps/rush-serve-dashboard/src/styles/terminal-pane.css b/apps/rush-serve-dashboard/src/styles/terminal-pane.css
new file mode 100644
index 00000000000..3c80f1561c6
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/styles/terminal-pane.css
@@ -0,0 +1,113 @@
+/* Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. */
+/* See LICENSE in the project root for license information. */
+
+#resizer {
+ width: 6px;
+ cursor: col-resize;
+ background: linear-gradient(90deg, transparent, rgba(var(--rgb-accent), 0.06), transparent);
+ transition: background 0.12s ease;
+}
+
+#resizer:hover {
+ background: linear-gradient(90deg, transparent, rgba(var(--rgb-accent), 0.12), transparent);
+}
+
+#term-autoscroll {
+ display: none;
+}
+
+.terminal-container {
+ background: #000;
+ border-left: 1px solid var(--border);
+ color: var(--text);
+ font-size: 0.75rem;
+ display: flex;
+ flex-direction: column;
+ align-self: stretch;
+ flex: 0 0 360px;
+ width: 360px;
+ min-width: 36px;
+ transition:
+ width 0.18s ease,
+ box-shadow 0.18s ease;
+}
+
+.terminal-header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 6px 8px;
+ border-bottom: 1px solid var(--border);
+}
+
+.terminal-title {
+ font-weight: 600;
+ font-size: 0.8rem;
+}
+
+.terminal-controls {
+ margin-left: auto;
+ display: flex;
+ gap: 6px;
+ align-items: center;
+}
+
+.terminal-container.hidden {
+ display: none !important;
+}
+
+#resizer.hidden {
+ display: none !important;
+}
+
+.terminal-body {
+ flex: 1 1 auto;
+ padding: 8px;
+ overflow: auto;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
+ white-space: pre;
+ font-size: 12px;
+ line-height: 1.25;
+}
+
+.term-chunk.stdout {
+ color: var(--text);
+}
+
+.term-chunk.stderr {
+ color: var(--danger);
+}
+
+.terminal-controls button.icon-btn {
+ width: auto;
+ height: auto;
+ padding: 4px 8px;
+}
+
+.terminal-container.term-flash .terminal-header {
+ animation: termFlash 300ms ease-in-out;
+}
+
+/* Ensure the top-bar terminal icon specifically uses accent when active */
+#toggle-terminal-btn.active,
+#toggle-terminal-btn.active .codicon {
+ color: var(--accent) !important;
+ fill: var(--accent) !important;
+}
+
+.codicon.vertical {
+ display: inline-block;
+ transform: rotate(90deg);
+}
+
+@keyframes termFlash {
+ 0% {
+ box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
+ }
+ 30% {
+ box-shadow: 0 0 8px 2px rgba(59, 130, 246, 0.18);
+ }
+ 100% {
+ box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
+ }
+}
diff --git a/apps/rush-serve-dashboard/src/styles/top-bar.css b/apps/rush-serve-dashboard/src/styles/top-bar.css
new file mode 100644
index 00000000000..e8ad859010d
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/styles/top-bar.css
@@ -0,0 +1,112 @@
+/* Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. */
+/* See LICENSE in the project root for license information. */
+
+/* Scoped styles for the dashboard top bar component */
+#top-bar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem 0.75rem;
+ align-items: baseline;
+ padding: 0.5rem 0.75rem;
+ background: var(--panel);
+ border-bottom: 1px solid var(--border);
+ max-height: 40vh;
+ overflow-y: auto;
+}
+
+#top-bar > * {
+ display: flex;
+ align-items: baseline;
+}
+
+#top-bar .icon-btn {
+ width: auto;
+ height: auto;
+ padding: 0 4px;
+ font-size: 0.8rem;
+ line-height: 1.05;
+ align-items: baseline;
+}
+
+#top-bar .icon-btn .codicon,
+#top-bar .icon-btn span[aria-hidden='true'] {
+ position: relative;
+ top: 2px;
+ line-height: 1;
+}
+
+#top-bar #actions,
+#top-bar #graph-state,
+#top-bar #view-controls,
+#top-bar #overall-status {
+ align-items: baseline;
+}
+
+#top-bar #overall-status {
+ display: flex;
+ align-items: center;
+}
+
+#status-emoji {
+ position: relative;
+ top: 1px;
+ font-size: 1rem;
+ line-height: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+}
+
+#status-pill {
+ padding: 2px 10px;
+ border-radius: 999px;
+ font-size: 0.7rem;
+ font-weight: 600;
+ line-height: 1.05;
+}
+
+/* Connected (disconnect) state = red foreground */
+#top-bar #connection-form #connect-btn[data-state='connected'] {
+ color: var(--danger);
+}
+
+/* Disconnected state = accent (blue) foreground */
+#top-bar #connection-form #connect-btn[data-state='disconnected'] {
+ color: var(--accent);
+}
+
+/* Remove default light-mode spinner arrows and supply dark replacements */
+#top-bar input[type='number']::-webkit-inner-spin-button,
+#top-bar input[type='number']::-webkit-outer-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+
+#top-bar input[type='number'] {
+ background-image:
+ linear-gradient(135deg, var(--muted) 50%, transparent 50%),
+ linear-gradient(315deg, var(--muted) 50%, transparent 50%),
+ linear-gradient(45deg, var(--muted) 50%, transparent 50%),
+ linear-gradient(225deg, var(--muted) 50%, transparent 50%);
+ background-size:
+ 6px 6px,
+ 6px 6px,
+ 6px 6px,
+ 6px 6px;
+ background-position:
+ calc(100% - 10px) 6px,
+ calc(100% - 6px) 6px,
+ calc(100% - 10px) calc(100% - 6px),
+ calc(100% - 6px) calc(100% - 6px);
+ background-repeat: no-repeat;
+ padding-right: 20px;
+ appearance: textfield;
+ -moz-appearance: textfield;
+}
+
+#top-bar input[type='number']:focus {
+ outline: 1px solid var(--accent);
+ outline-offset: 0;
+}
diff --git a/apps/rush-serve-dashboard/src/test/actionWiring.test.ts b/apps/rush-serve-dashboard/src/test/actionWiring.test.ts
new file mode 100644
index 00000000000..418179ec7e3
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/test/actionWiring.test.ts
@@ -0,0 +1,227 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+import { wireLeftBarActions } from '../modules/leftBar';
+import { wireMainBarActions } from '../modules/mainBar';
+import { createSelectionBarController } from '../modules/selectionBar';
+import {
+ computeWsUrl,
+ overallStatusText,
+ setConnected,
+ showConnectingStatus,
+ updateManagerState,
+ updateStatusPill,
+ type ITopBarRefs
+} from '../modules/topBar';
+import { wireViewBar } from '../modules/viewBar';
+
+function click(id: string): void {
+ document.getElementById(id)?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+}
+
+describe('action wiring', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ window.history.replaceState(null, '', '/dashboard');
+ });
+
+ it('wires selection commands and safe/unsafe enabled-state mode', () => {
+ document.body.innerHTML = `
+
+
+
+
+ `;
+ const sendCommand = jest.fn();
+ const clearSelectionAndRender = jest.fn();
+ const expandSelectionDependencies = jest.fn();
+ const expandSelectionConsumers = jest.fn();
+ wireLeftBarActions({
+ sendCommand,
+ getSelection: () => new Set(['build']),
+ clearSelectionAndRender,
+ expandSelectionDependencies,
+ expandSelectionConsumers
+ });
+
+ click('invalidate-btn');
+ click('selection-mode-btn');
+ click('set-enabled-disabled-btn');
+ click('expand-deps-btn');
+ click('expand-consumers-btn');
+ click('clear-selection-btn');
+
+ expect(sendCommand).toHaveBeenNthCalledWith(1, { command: 'invalidate', operationNames: ['build'] });
+ expect(sendCommand).toHaveBeenNthCalledWith(2, {
+ command: 'set-enabled-states',
+ operationNames: ['build'],
+ targetState: 'never',
+ mode: 'unsafe'
+ });
+ expect(expandSelectionDependencies).toHaveBeenCalledTimes(1);
+ expect(expandSelectionConsumers).toHaveBeenCalledTimes(1);
+ expect(clearSelectionAndRender).toHaveBeenCalledTimes(1);
+ });
+
+ it('wires manager commands and keyboard selection', () => {
+ document.body.innerHTML =
+ '';
+ const debugBtn: HTMLButtonElement = document.createElement('button');
+ const verboseBtn: HTMLButtonElement = document.createElement('button');
+ const parallelismInput: HTMLInputElement = document.createElement('input');
+ const playPauseBtn: HTMLButtonElement = document.createElement('button');
+ const sendCommand = jest.fn();
+ const connect = jest.fn();
+ const setSelection = jest.fn();
+ const clearSelection = jest.fn();
+ const render = jest.fn();
+ parallelismInput.value = '4';
+
+ wireMainBarActions({
+ connect,
+ disconnect: jest.fn(),
+ isConnected: () => false,
+ sendCommand,
+ getGraphSettings: () => ({ debugMode: false, verbose: true, pauseNextIteration: false }),
+ debugBtn,
+ verboseBtn,
+ parallelismInput,
+ playPauseBtn,
+ getOperationNames: () => ['build', 'test'],
+ setSelection,
+ clearSelection,
+ hasSelection: () => true,
+ render
+ });
+
+ click('connect-btn');
+ click('execute-btn');
+ debugBtn.click();
+ verboseBtn.click();
+ parallelismInput.dispatchEvent(new Event('change'));
+ playPauseBtn.click();
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', ctrlKey: true }));
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
+
+ expect(connect).toHaveBeenCalledTimes(1);
+ expect(sendCommand.mock.calls.map((call: unknown[]) => call[0])).toEqual([
+ { command: 'execute' },
+ { command: 'set-debug', value: true },
+ { command: 'set-verbose', value: false },
+ { command: 'set-parallelism', parallelism: 4 },
+ { command: 'set-pause-next-iteration', value: true }
+ ]);
+ expect(setSelection).toHaveBeenCalledWith(new Set(['build', 'test']));
+ expect(clearSelection).toHaveBeenCalledTimes(1);
+ expect(render).toHaveBeenCalledTimes(2);
+ });
+
+ it('wires view, filter, and search controls', () => {
+ document.body.innerHTML = `
+
+
+ `;
+ let view: 'table' | 'graph' = 'table';
+ let filter: 'all' | 'failed-warn' = 'all';
+ const markGraphDirty = jest.fn();
+ const render = jest.fn();
+ const setSearchQuery = jest.fn();
+ wireViewBar({
+ getView: () => view,
+ setView: (next) => (view = next),
+ getFilter: () => filter,
+ setFilter: (next) => (filter = next),
+ setSearchQuery,
+ markGraphDirty,
+ render
+ });
+
+ const graphRadio = document.querySelector('input[value="graph"]') as HTMLInputElement;
+ graphRadio.checked = true;
+ graphRadio.dispatchEvent(new Event('change'));
+ const filterSelect = document.getElementById('filter-select') as HTMLSelectElement;
+ filterSelect.value = 'failed-warn';
+ filterSelect.dispatchEvent(new Event('change'));
+ const searchInput = document.getElementById('name-search') as HTMLInputElement;
+ searchInput.value = 'build';
+ searchInput.dispatchEvent(new Event('input'));
+
+ expect(document.getElementById('left')?.style.display).toBe('none');
+ expect(document.getElementById('right')?.style.display).toBe('');
+ expect(setSearchQuery).toHaveBeenCalledWith('build');
+ expect(markGraphDirty).toHaveBeenCalledTimes(2);
+ expect(window.location.search).toContain('view=graph');
+ expect(window.location.search).toContain('filter=failed-warn');
+ });
+});
+
+describe('top and selection bars', () => {
+ beforeEach(() => {
+ document.body.innerHTML = `
+
+
+
+
+
+
+ `;
+ });
+
+ function getRefs(): ITopBarRefs {
+ return {
+ connectBtn: document.getElementById('connect-btn') ?? undefined,
+ statusPill: document.getElementById('status-pill') ?? undefined,
+ statusEmojiEl: document.getElementById('status-emoji') ?? undefined,
+ debugBtn: document.getElementById('debug-btn') ?? undefined,
+ verboseBtn: document.getElementById('verbose-btn') ?? undefined,
+ playPauseBtn: document.getElementById('play-pause-btn') ?? undefined,
+ parallelismInput: (document.getElementById('parallelism') as HTMLInputElement) ?? undefined,
+ managerStateEl: document.getElementById('manager-state') ?? undefined
+ };
+ }
+
+ it('formats URLs and updates connection and status state', () => {
+ const refs: ITopBarRefs = getRefs();
+ expect(computeWsUrl({ host: 'example.test', protocol: 'https:' } as Location)).toBe(
+ 'wss://example.test/ws'
+ );
+ expect(overallStatusText('SuccessWithWarning')).toBe('WARNING');
+
+ showConnectingStatus(refs.statusPill, refs.statusEmojiEl, () => 'waiting');
+ expect(refs.statusPill?.textContent).toBe('CONNECTING');
+ const socket: WebSocket = { readyState: WebSocket.OPEN } as WebSocket;
+ updateStatusPill(refs, socket, { status: 'Success' }, () => 'success');
+ expect(refs.statusPill?.classList.contains('status-Success')).toBe(true);
+
+ const updateSelectionUI = jest.fn();
+ setConnected(refs, true, updateSelectionUI, ['execute-btn']);
+ expect(refs.connectBtn?.dataset.state).toBe('connected');
+ expect((document.getElementById('execute-btn') as HTMLButtonElement).disabled).toBe(false);
+ expect(updateSelectionUI).toHaveBeenCalledTimes(1);
+ });
+
+ it('updates manager and selection controls', () => {
+ const refs: ITopBarRefs = getRefs();
+ updateManagerState(refs, {
+ debugMode: true,
+ verbose: false,
+ pauseNextIteration: true,
+ parallelism: 8,
+ hasScheduledIteration: true
+ });
+ expect(refs.debugBtn?.getAttribute('aria-pressed')).toBe('true');
+ expect(refs.playPauseBtn?.title).toBe('Resume automatic iterations');
+ expect(refs.parallelismInput?.value).toBe('8');
+ expect(document.getElementById('execute-btn')?.classList.contains('queued')).toBe(true);
+
+ const controller = createSelectionBarController({
+ getSelection: () => new Set(['build']),
+ getCurrentView: () => 'graph',
+ isConnected: () => true
+ });
+ controller.updateSelectionUI();
+ expect(document.getElementById('view-heading-text')?.textContent).toBe('Dependency Graph');
+ expect(document.getElementById('selection-count')?.textContent).toBe('1 selected');
+ expect((document.getElementById('invalidate-btn') as HTMLButtonElement).disabled).toBe(false);
+ });
+});
diff --git a/apps/rush-serve-dashboard/src/test/dashboard.test.ts b/apps/rush-serve-dashboard/src/test/dashboard.test.ts
new file mode 100644
index 00000000000..5731fbecf4b
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/test/dashboard.test.ts
@@ -0,0 +1,71 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+const sockets: MockWebSocket[] = [];
+
+class MockWebSocket extends EventTarget {
+ public static readonly OPEN: number = 1;
+ public readyState: number = 0;
+
+ public constructor() {
+ super();
+ sockets.push(this);
+ }
+
+ public send(): void {}
+ public close(): void {}
+}
+
+describe('dashboard entrypoint', () => {
+ const originalWebSocket: typeof WebSocket = globalThis.WebSocket;
+
+ beforeEach(() => {
+ jest.resetModules();
+ sockets.length = 0;
+ globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
+ window.history.replaceState(null, '', '/dashboard');
+ document.body.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ });
+
+ afterEach(() => {
+ globalThis.WebSocket = originalWebSocket;
+ });
+
+ it('connects and renders a synchronization message', async () => {
+ const consoleSpy = jest.spyOn(window.console, 'log').mockImplementation(() => {});
+ await import('../dashboard');
+ const socket: MockWebSocket = sockets[0];
+ socket.readyState = MockWebSocket.OPEN;
+ socket.dispatchEvent(new Event('open'));
+ socket.dispatchEvent(
+ new MessageEvent('message', {
+ data: JSON.stringify({
+ event: 'sync',
+ operations: [{ name: 'package-a (build)', packageName: 'package-a', phaseName: '_phase:build' }],
+ currentExecutionStates: [{ name: 'package-a (build)', status: 'Success' }],
+ graphState: { status: 'Success', parallelism: 2 },
+ sessionInfo: { actionName: 'start', repositoryIdentifier: 'rushstack' }
+ })
+ })
+ );
+
+ expect(document.title).toBe('start β rushstack');
+ expect(document.getElementById('table-stats')?.textContent).toBe('1 operations');
+ expect(document.querySelector('#operations-table tbody')?.textContent).toContain('package-a');
+ expect(document.getElementById('status-pill')?.textContent).toBe('SUCCESS');
+ consoleSpy.mockRestore();
+ });
+});
diff --git a/apps/rush-serve-dashboard/src/test/dashboardState.test.ts b/apps/rush-serve-dashboard/src/test/dashboardState.test.ts
new file mode 100644
index 00000000000..fbf296c0072
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/test/dashboardState.test.ts
@@ -0,0 +1,182 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+import { AnsiSgrParser } from '../modules/ansiSgrParser';
+import {
+ applyExecutionStates,
+ patchOperationsFromPayload,
+ setOperationsFromPayload,
+ setQueuedStates,
+ toLastExecutionResultsMap
+} from '../modules/dashboardMutations';
+import { computeFilterSetsCore, pruneGraphOperations } from '../modules/graphFiltering';
+import {
+ buildRunPolicyText,
+ buildTooltip,
+ computeDisplayStatus,
+ enabledGlyph,
+ getStatusColors,
+ statusEmoji
+} from '../modules/statusHelpers';
+import { loadDashboardUrlState, syncDashboardUrlState } from '../modules/urlState';
+
+describe(AnsiSgrParser.name, () => {
+ it('applies styles, preserves state between chunks, and resets it', () => {
+ const parser: AnsiSgrParser = new AnsiSgrParser();
+
+ expect(parser.process('plain\u001b[1;31mbold red')).toEqual([
+ { text: 'plain', style: '' },
+ { text: 'bold red', style: 'color: #a00; font-weight: 700' }
+ ]);
+ expect(parser.process(' continued\u001b[0m reset')).toEqual([
+ { text: ' continued', style: 'color: #a00; font-weight: 700' },
+ { text: ' reset', style: '' }
+ ]);
+ });
+
+ it('supports bright, background, underline, and inverse styles', () => {
+ const parser: AnsiSgrParser = new AnsiSgrParser();
+
+ expect(parser.process('\u001b[4;7;96;41mstyled')).toEqual([
+ {
+ text: 'styled',
+ style: 'color: #55ffff; background-color: #a00; text-decoration: underline; filter: invert(100%)'
+ }
+ ]);
+ });
+
+ it('leaves malformed escape sequences as text', () => {
+ expect(new AnsiSgrParser().process('before\u001b[not-sgr')).toEqual([
+ { text: 'before\u001b[not-sgr', style: '' }
+ ]);
+ });
+});
+
+describe('dashboard mutations', () => {
+ it('replaces, patches, and updates operation state', () => {
+ const operations: Map = new Map([
+ ['old', { name: 'old' }]
+ ]);
+ const executionStates: Map = new Map();
+
+ setOperationsFromPayload(operations, [{ name: 'build', status: 'Ready' }]);
+ patchOperationsFromPayload(operations, [{ name: 'test', status: 'Waiting' }]);
+ applyExecutionStates(operations, executionStates, [
+ { name: 'build', status: 'Success', isActive: true },
+ { name: 'unknown', status: 'Failure' }
+ ]);
+
+ expect(Array.from(operations.keys())).toEqual(['build', 'test']);
+ expect(operations.get('build')).toMatchObject({ status: 'Success', isActive: true });
+ expect(executionStates.has('unknown')).toBe(true);
+ });
+
+ it('replaces queued states and indexes prior results', () => {
+ const queuedStates: Map = new Map([['old', { name: 'old' }]]);
+ setQueuedStates(queuedStates, [{ name: 'next', status: 'Queued' }]);
+
+ expect(Array.from(queuedStates.keys())).toEqual(['next']);
+ expect(toLastExecutionResultsMap([{ name: 'build', status: 'Failure' }]).get('build')?.status).toBe(
+ 'Failure'
+ );
+ expect(toLastExecutionResultsMap(undefined).size).toBe(0);
+ });
+});
+
+describe('graph filtering', () => {
+ it('merges execution state before filtering by status and search text', () => {
+ const operations: Map = new Map([
+ ['package-a (build)', { name: 'package-a (build)', status: 'Ready' }],
+ ['package-b (test)', { name: 'package-b (test)', status: 'Success' }]
+ ]);
+
+ const result = computeFilterSetsCore({
+ operations,
+ executionStates: new Map([
+ ['package-a (build)', { name: 'package-a (build)', status: 'Failure', isActive: true }]
+ ]),
+ currentFilter: 'failed-warn',
+ searchQuery: 'PACKAGE-A',
+ computeDisplayStatus: (operation) => operation.status || 'Ready'
+ });
+
+ expect(result.visibleOperations.map((operation) => operation.name)).toEqual(['package-a (build)']);
+ expect(result.filteredOutNames).toEqual(new Set(['package-b (test)']));
+ expect(operations.get('package-a (build)')).toMatchObject({ status: 'Failure', isActive: true });
+ });
+
+ it('removes no-op nodes and reconnects their dependents', () => {
+ const result = pruneGraphOperations([
+ { name: 'compile', dependencies: [] },
+ { name: 'noop', dependencies: ['compile'], noop: true },
+ { name: 'test', dependencies: ['noop'] }
+ ]);
+
+ expect(result).toEqual([
+ { name: 'compile', dependencies: [] },
+ { name: 'test', dependencies: ['compile'] }
+ ]);
+ });
+});
+
+describe('status helpers', () => {
+ it('uses current state, prior results, and defaults in priority order', () => {
+ expect(
+ computeDisplayStatus(
+ { name: 'build', status: 'Ready' },
+ new Map([['build', { name: 'build', status: 'Executing' }]]),
+ new Map()
+ )
+ ).toBe('Executing');
+ expect(
+ computeDisplayStatus(
+ { name: 'build', runInThisIteration: false },
+ new Map(),
+ new Map([['build', { name: 'build', status: 'Success' }]])
+ )
+ ).toBe('Success');
+ expect(computeDisplayStatus({ name: 'noop', noop: true }, new Map(), new Map())).toBe('NoOp');
+ });
+
+ it('formats status, policy, and tooltip text', () => {
+ expect(statusEmoji('Failure')).toBe('β');
+ expect(statusEmoji('custom')).toBe('β’');
+ expect(enabledGlyph({ name: 'build', enabled: 'never' })).toBe('π΄');
+ expect(buildRunPolicyText({ name: 'build', enabled: 'ignore-dependency-changes' })).toBe(
+ 'Ignores dependency changes'
+ );
+ expect(buildTooltip({ name: 'build', isActive: true }, 'Success')).toContain('Has in-memory state');
+ });
+
+ it('reads status colors with fallback variables', () => {
+ document.documentElement.style.setProperty('--status-ready', '#111111');
+ document.documentElement.style.setProperty('--warn', '#222222');
+ document.documentElement.style.setProperty('--danger', '#333333');
+
+ expect(getStatusColors()).toMatchObject({
+ Ready: '#111111',
+ Executing: '#222222',
+ Failure: '#333333'
+ });
+ });
+});
+
+describe('URL state', () => {
+ it('loads supported values and ignores unsupported values', () => {
+ expect(loadDashboardUrlState('?view=graph&filter=failed-warn')).toEqual({
+ view: 'graph',
+ filter: 'failed-warn'
+ });
+ expect(loadDashboardUrlState('?view=cards&filter=success')).toEqual({ view: 'table', filter: 'all' });
+ });
+
+ it('updates dashboard parameters while preserving other URL state', () => {
+ window.history.replaceState(null, '', '/dashboard?custom=value#output');
+
+ syncDashboardUrlState('graph', 'failed-warn');
+
+ expect(window.location.pathname + window.location.search + window.location.hash).toBe(
+ '/dashboard?custom=value&view=graph&filter=failed-warn#output'
+ );
+ });
+});
diff --git a/apps/rush-serve-dashboard/src/test/dashboardWebSocket.test.ts b/apps/rush-serve-dashboard/src/test/dashboardWebSocket.test.ts
new file mode 100644
index 00000000000..1d5739e67fb
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/test/dashboardWebSocket.test.ts
@@ -0,0 +1,100 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+import { createDashboardWebSocketController } from '../modules/dashboardWebSocket';
+
+const mockSockets: MockWebSocket[] = [];
+
+class MockWebSocket extends EventTarget {
+ public static readonly OPEN: number = 1;
+ public readonly sent: string[] = [];
+ public readonly url: string;
+ public readyState: number = 0;
+ public closed: boolean = false;
+
+ public constructor(url: string) {
+ super();
+ this.url = url;
+ mockSockets.push(this);
+ }
+
+ public send(data: string): void {
+ this.sent.push(data);
+ }
+
+ public close(): void {
+ this.closed = true;
+ }
+}
+
+describe('dashboard WebSocket controller', () => {
+ const originalWebSocket: typeof WebSocket = globalThis.WebSocket;
+
+ beforeEach(() => {
+ mockSockets.length = 0;
+ jest.useFakeTimers();
+ globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ globalThis.WebSocket = originalWebSocket;
+ });
+
+ it('connects, parses messages, sends commands, and reports lifecycle events', () => {
+ const onConnectedStateChange = jest.fn();
+ const onOpen = jest.fn();
+ const onParsedMessage = jest.fn();
+ const onParseError = jest.fn();
+ const controller = createDashboardWebSocketController({
+ getUrl: () => 'ws://localhost/ws',
+ onConnecting: jest.fn(),
+ onConnectedStateChange,
+ onOpen,
+ onClose: jest.fn(),
+ onError: jest.fn(),
+ onParsedMessage,
+ onParseError,
+ onLog: jest.fn()
+ });
+
+ controller.connect();
+ const socket: MockWebSocket = mockSockets[0];
+ socket.readyState = MockWebSocket.OPEN;
+ socket.dispatchEvent(new Event('open'));
+ socket.dispatchEvent(new MessageEvent('message', { data: '{"event":"sync"}' }));
+ socket.dispatchEvent(new MessageEvent('message', { data: 'invalid' }));
+ controller.sendCommand({ command: 'execute' });
+
+ expect(controller.isConnected()).toBe(true);
+ expect(onConnectedStateChange).toHaveBeenLastCalledWith(true);
+ expect(onOpen).toHaveBeenCalledTimes(1);
+ expect(onParsedMessage).toHaveBeenCalledWith({ event: 'sync' });
+ expect(onParseError).toHaveBeenCalledTimes(1);
+ expect(socket.sent).toEqual(['{"command":"execute"}']);
+ });
+
+ it('reconnects after an unexpected close but not after a manual disconnect', () => {
+ const controller = createDashboardWebSocketController({
+ getUrl: () => 'ws://localhost/ws',
+ onConnecting: jest.fn(),
+ onConnectedStateChange: jest.fn(),
+ onOpen: jest.fn(),
+ onClose: jest.fn(),
+ onError: jest.fn(),
+ onParsedMessage: jest.fn(),
+ onParseError: jest.fn(),
+ onLog: jest.fn()
+ });
+
+ controller.connect();
+ mockSockets[0].dispatchEvent(new Event('close'));
+ jest.advanceTimersByTime(4000);
+ expect(mockSockets).toHaveLength(2);
+
+ controller.disconnect();
+ expect(mockSockets[1].closed).toBe(true);
+ jest.advanceTimersByTime(4000);
+ expect(mockSockets).toHaveLength(2);
+ });
+});
diff --git a/apps/rush-serve-dashboard/src/test/graphSelection.test.ts b/apps/rush-serve-dashboard/src/test/graphSelection.test.ts
new file mode 100644
index 00000000000..a491bc2289b
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/test/graphSelection.test.ts
@@ -0,0 +1,72 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+import { createGraphSelectionController } from '../modules/graphSelection';
+
+describe('graph selection controller', () => {
+ it('selects nodes and expands dependencies and consumers transitively', () => {
+ const graphEl: HTMLDivElement = document.createElement('div');
+ let selection: Set = new Set();
+ const onSelectionChanged = jest.fn();
+ const controller = createGraphSelectionController({
+ graphEl,
+ getCurrentView: () => 'graph',
+ getSelection: () => selection,
+ setSelection: (next) => (selection = next),
+ getOperations: () =>
+ new Map([
+ ['compile', { name: 'compile' }],
+ ['build', { name: 'build', dependencies: ['compile'] }],
+ ['test', { name: 'test', dependencies: ['build'] }]
+ ]),
+ getGraphNodePositions: () => new Map(),
+ graphNodeWidth: 100,
+ graphNodeHeight: 40,
+ onSelectionChanged,
+ onLiveSelectionChanged: jest.fn()
+ });
+
+ controller.singleSelect('test');
+ controller.expandSelectionDependencies();
+ expect(selection).toEqual(new Set(['test', 'build', 'compile']));
+ controller.singleSelect('compile');
+ controller.expandSelectionConsumers();
+ expect(selection).toEqual(new Set(['compile', 'build', 'test']));
+ controller.toggleSelect('build');
+ expect(selection).toEqual(new Set(['compile', 'test']));
+ expect(onSelectionChanged).toHaveBeenCalledTimes(5);
+ });
+
+ it('replaces selection using a marquee over graph nodes', () => {
+ const graphEl: HTMLDivElement = document.createElement('div');
+ document.body.appendChild(graphEl);
+ graphEl.getBoundingClientRect = () => ({ left: 0, top: 0 }) as DOMRect;
+ let selection: Set = new Set(['outside']);
+ const onLiveSelectionChanged = jest.fn();
+ const controller = createGraphSelectionController({
+ graphEl,
+ getCurrentView: () => 'graph',
+ getSelection: () => selection,
+ setSelection: (next) => (selection = next),
+ getOperations: () => new Map(),
+ getGraphNodePositions: () =>
+ new Map([
+ ['inside', { x: 10, y: 10 }],
+ ['outside', { x: 200, y: 200 }]
+ ]),
+ graphNodeWidth: 50,
+ graphNodeHeight: 30,
+ onSelectionChanged: jest.fn(),
+ onLiveSelectionChanged
+ });
+ controller.wireGraphMarqueeSelection();
+
+ graphEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0, clientX: 0, clientY: 0 }));
+ graphEl.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 100, clientY: 100 }));
+ window.dispatchEvent(new MouseEvent('mouseup'));
+
+ expect(selection).toEqual(new Set(['inside']));
+ expect(onLiveSelectionChanged).toHaveBeenCalled();
+ expect(graphEl.querySelector('.graph-marquee')).toBeNull();
+ });
+});
diff --git a/apps/rush-serve-dashboard/src/test/graphView.test.ts b/apps/rush-serve-dashboard/src/test/graphView.test.ts
new file mode 100644
index 00000000000..f11132ca9ea
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/test/graphView.test.ts
@@ -0,0 +1,57 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+import { createGraphViewController, graphState } from '../modules/graphView';
+
+describe('graph view controller', () => {
+ it('renders nodes, removes transitive edges, updates indicators, and wires selection', () => {
+ document.documentElement.style.setProperty('--status-success', '#00aa00');
+ document.body.innerHTML = '';
+ const graphEl: HTMLElement = document.getElementById('graph') as HTMLElement;
+ const edgesSvg: SVGSVGElement = document.getElementById('edges') as unknown as SVGSVGElement;
+ const operations = new Map([
+ ['compile', { name: 'compile', status: 'Success', enabled: 'affected' }],
+ ['build', { name: 'build', dependencies: ['compile'], status: 'Success', isActive: true }],
+ ['test', { name: 'test', dependencies: ['compile', 'build'], status: 'Success', enabled: 'never' }]
+ ]);
+ const renderPhaseLegend = jest.fn();
+ const singleSelect = jest.fn();
+ const toggleSelect = jest.fn();
+ const controller = createGraphViewController({
+ graphEl,
+ edgesSvg,
+ getOperations: () => operations,
+ getExecutionStates: () => new Map(),
+ getQueuedStates: () => new Map([['test', { name: 'test', runInThisIteration: true }]]),
+ getSelection: () => new Set(['compile', 'build']),
+ getFilteredOutNames: () => new Set(),
+ getSearchFilteredOutNames: () => new Set(),
+ getLastExecutionResults: () => new Map(),
+ getComputeDisplayStatus: () => 'Success',
+ getStatusEmoji: () => 'ok',
+ getOverallStatusText: (status) => status || '',
+ renderPhaseLegend,
+ singleSelect,
+ toggleSelect
+ });
+
+ controller.ensureGraph();
+
+ expect(graphState.nodeElements.size).toBe(3);
+ expect(graphState.edgeElements.map(({ from, to }) => `${from}->${to}`).sort()).toEqual([
+ 'build->compile',
+ 'test->build'
+ ]);
+ expect(graphState.nodeElements.get('build')?.querySelector('.active-indicator')).not.toBeNull();
+ expect(graphState.nodeElements.get('test')?.querySelector('.pending-indicator')).not.toBeNull();
+ expect(graphState.nodeElements.get('test')?.querySelector('.enabled-indicator')?.textContent).toBe('π΄');
+ expect(renderPhaseLegend).toHaveBeenCalled();
+
+ graphState.nodeElements.get('compile')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ graphState.nodeElements
+ .get('build')
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true, ctrlKey: true }));
+ expect(singleSelect).toHaveBeenCalledWith('compile');
+ expect(toggleSelect).toHaveBeenCalledWith('build');
+ });
+});
diff --git a/apps/rush-serve-dashboard/src/test/outputPanels.test.ts b/apps/rush-serve-dashboard/src/test/outputPanels.test.ts
new file mode 100644
index 00000000000..0ce1e5ad2c8
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/test/outputPanels.test.ts
@@ -0,0 +1,100 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+import { createPhaseLegendController } from '../modules/phaseLegend';
+import { createTerminalPaneController } from '../modules/terminalPane';
+
+describe('phase and legend controller', () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ document.body.innerHTML = '';
+ });
+
+ it('summarizes visible phases by priority and links operation logs', () => {
+ const operations = new Map([
+ ['build-a', { name: 'build-a', phaseName: '_phase:build', logFileURLs: { text: '/logs/build-a.log' } }],
+ ['build-b', { name: 'build-b', phaseName: '_phase:build' }],
+ ['hidden', { name: 'hidden', phaseName: '_phase:test' }],
+ ['executing', { name: 'executing', phaseName: '_phase:test' }]
+ ]);
+ const statuses: Record = {
+ 'build-a': 'Failure',
+ 'build-b': 'Success',
+ hidden: 'Success',
+ executing: 'Executing'
+ };
+ const controller = createPhaseLegendController({
+ phaseGroupsEl: document.getElementById('phases') ?? undefined,
+ legendEl: document.getElementById('legend') ?? undefined,
+ getOperations: () => operations,
+ getGraphVisibleNames: () => new Set(['build-a', 'build-b']),
+ computeDisplayStatus: (operation) => statuses[operation.name],
+ statusEmoji: (status) => status,
+ overallStatusText: (status) => status || '',
+ getStatusColors: () => ({ Failure: '#ff0000' })
+ });
+
+ controller.renderAll();
+
+ const phases: HTMLElement = document.getElementById('phases') as HTMLElement;
+ expect(phases.textContent).toContain('build');
+ expect(phases.textContent).toContain('Failure');
+ expect(phases.textContent).toContain('executing');
+ expect(phases.textContent).not.toContain('hidden');
+ const logLink: HTMLAnchorElement = phases.querySelector('a') as HTMLAnchorElement;
+ expect(logLink.getAttribute('href')).toBe('/logs/build-a.log');
+ expect(logLink.rel).toBe('noopener noreferrer');
+
+ const collapseButton: HTMLButtonElement = document.getElementById(
+ 'legend-collapse-btn'
+ ) as HTMLButtonElement;
+ collapseButton.click();
+ expect(document.getElementById('legend')?.classList.contains('collapsed')).toBe(true);
+ expect(window.localStorage.getItem('rushServeLegendCollapsed')).toBe('1');
+ });
+});
+
+describe('terminal pane controller', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ document.body.innerHTML = `
+
+
+ `;
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('renders ANSI chunks and wires clear, auto-scroll, and visibility controls', () => {
+ const terminalEl: HTMLElement = document.getElementById('terminal') as HTMLElement;
+ const terminalBody: HTMLElement = document.getElementById('body') as HTMLElement;
+ terminalEl.getBoundingClientRect = () => ({ width: 320 }) as DOMRect;
+ const controller = createTerminalPaneController({
+ terminalEl,
+ terminalBody,
+ termClearBtn: document.getElementById('clear') ?? undefined,
+ termAutoScrollCheckbox: document.getElementById('autoscroll-checkbox') as HTMLInputElement,
+ termAutoscrollBtn: document.getElementById('autoscroll') ?? undefined,
+ toggleTerminalBtn: document.getElementById('toggle') ?? undefined,
+ resizerEl: document.getElementById('resizer') ?? undefined
+ });
+
+ controller.appendChunk('stderr', '\u001b[31mfailed');
+ const chunk: HTMLElement = terminalBody.querySelector('.term-chunk') as HTMLElement;
+ expect(chunk.textContent).toBe('failed');
+ expect(chunk.classList.contains('stderr')).toBe(true);
+ expect(chunk.style.color).toBe('rgb(170, 0, 0)');
+ expect(terminalEl.classList.contains('term-flash')).toBe(true);
+ jest.advanceTimersByTime(350);
+ expect(terminalEl.classList.contains('term-flash')).toBe(false);
+
+ document.getElementById('autoscroll')?.click();
+ expect((document.getElementById('autoscroll-checkbox') as HTMLInputElement).checked).toBe(false);
+ document.getElementById('toggle')?.click();
+ expect(terminalEl.classList.contains('hidden')).toBe(true);
+ document.getElementById('clear')?.click();
+ expect(terminalBody.textContent).toBe('');
+ });
+});
diff --git a/apps/rush-serve-dashboard/src/test/tableView.test.ts b/apps/rush-serve-dashboard/src/test/tableView.test.ts
new file mode 100644
index 00000000000..7731211f735
--- /dev/null
+++ b/apps/rush-serve-dashboard/src/test/tableView.test.ts
@@ -0,0 +1,49 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+import { createTableViewController } from '../modules/tableView';
+
+describe('table view controller', () => {
+ it('renders a package/phase pivot safely and supports cell and group selection', () => {
+ document.body.innerHTML = '';
+ const operations = new Map([
+ ['a-build', { name: 'a-build', packageName: '', phaseName: '_phase:build', isActive: true }],
+ ['a-test', { name: 'a-test', packageName: '', phaseName: '_phase:test' }],
+ ['b-build', { name: 'b-build', packageName: 'package-b', phaseName: '_phase:build' }]
+ ]);
+ let selection: Set = new Set();
+ const onSelectionMutated = jest.fn();
+ const controller = createTableViewController({
+ tableHead: document.querySelector('thead') ?? undefined,
+ tableBody: document.querySelector('tbody') ?? undefined,
+ tableStats: document.getElementById('stats') ?? undefined,
+ getOperations: () => operations,
+ getFilteredOperations: () => Array.from(operations.values()),
+ getSelection: () => selection,
+ setSelection: (next) => (selection = next),
+ onSelectionMutated,
+ computeDisplayStatus: () => 'Success',
+ enabledGlyph: () => 'enabled',
+ buildRunPolicyText: () => 'Run if affected',
+ buildTooltip: (operation) => operation.name,
+ statusEmoji: () => 'ok',
+ overallStatusText: (status) => status || ''
+ });
+
+ controller.renderTable();
+
+ expect(document.querySelector('thead')?.textContent).toContain('build');
+ expect(document.querySelector('tbody')?.textContent).toContain('');
+ expect(document.querySelector('tbody')?.innerHTML).not.toContain('');
+ expect(document.getElementById('stats')?.textContent).toBe('3 operations');
+
+ const firstOperationCell = document.querySelector('td.pivot-cell[title="a-build"]') as HTMLElement;
+ firstOperationCell.click();
+ expect(selection).toEqual(new Set(['a-build']));
+
+ const firstPackageCell = document.querySelector('.pkg-cell') as HTMLElement;
+ firstPackageCell.click();
+ expect(selection).toEqual(new Set(['a-build', 'a-test']));
+ expect(onSelectionMutated).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/apps/rush-serve-dashboard/tsconfig.json b/apps/rush-serve-dashboard/tsconfig.json
new file mode 100644
index 00000000000..9688e20399b
--- /dev/null
+++ b/apps/rush-serve-dashboard/tsconfig.json
@@ -0,0 +1,3 @@
+{
+ "extends": "./node_modules/local-web-rig/profiles/app/tsconfig-base.json"
+}
diff --git a/common/config/rush-plugins/rush-serve-plugin.json b/common/config/rush-plugins/rush-serve-plugin.json
index 8d24cc8cc26..d42c234aeb5 100644
--- a/common/config/rush-plugins/rush-serve-plugin.json
+++ b/common/config/rush-plugins/rush-serve-plugin.json
@@ -3,5 +3,12 @@
"phasedCommands": ["start"],
"portParameterLongName": "--port",
"buildStatusWebSocketPath": "/ws",
- "logServePath": "/logs"
+ "logServePath": "/logs",
+ "globalRouting": [
+ {
+ "workspaceRelativeFolder": "rush-plugins/rush-serve-plugin/lib-esm/dashboard",
+ "servePath": "/dashboard",
+ "immutable": false
+ }
+ ]
}
diff --git a/common/config/rush/browser-approved-packages.json b/common/config/rush/browser-approved-packages.json
index 1a87efdf7fd..e3792ab126e 100644
--- a/common/config/rush/browser-approved-packages.json
+++ b/common/config/rush/browser-approved-packages.json
@@ -42,6 +42,10 @@
"name": "@rushstack/problem-matcher",
"allowedCategories": [ "libraries" ]
},
+ {
+ "name": "@rushstack/rush-serve-dashboard",
+ "allowedCategories": [ "libraries" ]
+ },
{
"name": "@rushstack/rush-themed-ui",
"allowedCategories": [ "libraries" ]
diff --git a/common/config/rush/command-line.json b/common/config/rush/command-line.json
index a8941e8753b..851603df42f 100644
--- a/common/config/rush/command-line.json
+++ b/common/config/rush/command-line.json
@@ -49,6 +49,8 @@
"watchOptions": {
// Act as though `--watch` is always passed. If false, adds support for passing `--watch`.
"alwaysWatch": true,
+ // Build a full watch graph so projects can become enabled without rebuilding the graph.
+ "includeAllProjectsInWatchGraph": true,
// During watch recompilation run both build and test for affected projects
"watchPhases": ["_phase:lite-build", "_phase:build", "_phase:test"]
}
diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml
index a6e518999de..34385bea698 100644
--- a/common/config/subspaces/default/pnpm-lock.yaml
+++ b/common/config/subspaces/default/pnpm-lock.yaml
@@ -453,6 +453,18 @@ importers:
specifier: ~5.8.2
version: 5.8.2
+ ../../../apps/rush-serve-dashboard:
+ devDependencies:
+ '@rushstack/heft':
+ specifier: workspace:*
+ version: link:../heft
+ eslint:
+ specifier: ~9.37.0
+ version: 9.37.0
+ local-web-rig:
+ specifier: workspace:*
+ version: link:../../rigs/local-web-rig
+
../../../apps/trace-import:
dependencies:
'@rushstack/node-core-library':
@@ -4583,7 +4595,7 @@ importers:
version: 1.2.22(@types/node@20.17.19)
'@rushstack/heft-node-rig':
specifier: 2.11.45
- version: 2.11.45(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)
+ version: 2.11.45(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)
'@types/jest':
specifier: 30.0.0
version: 30.0.0
@@ -5174,6 +5186,9 @@ importers:
'@rushstack/heft':
specifier: workspace:*
version: link:../../apps/heft
+ '@rushstack/rush-serve-dashboard':
+ specifier: workspace:*
+ version: link:../../apps/rush-serve-dashboard
'@types/compression':
specifier: ~1.7.2
version: 1.7.5(@types/express@4.17.21)
@@ -24642,7 +24657,7 @@ snapshots:
transitivePeerDependencies:
- '@types/node'
- '@rushstack/heft-jest-plugin@2.0.12(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/jest@30.0.0)(@types/node@20.17.19)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)(jest-environment-node@30.3.0)':
+ '@rushstack/heft-jest-plugin@2.0.12(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/jest@30.0.0)(@types/node@20.17.19)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)(jest-environment-node@30.3.0)':
dependencies:
'@jest/core': 30.3.0(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0))
'@jest/reporters': 30.3.0
@@ -24675,13 +24690,13 @@ snapshots:
transitivePeerDependencies:
- '@types/node'
- '@rushstack/heft-node-rig@2.11.45(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)':
+ '@rushstack/heft-node-rig@2.11.45(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)':
dependencies:
'@microsoft/api-extractor': 7.58.12(@types/node@20.17.19)
'@rushstack/eslint-config': 4.6.4(eslint@9.37.0)(typescript@5.8.2)
'@rushstack/heft': 1.2.22(@types/node@20.17.19)
'@rushstack/heft-api-extractor-plugin': 1.3.22(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19)
- '@rushstack/heft-jest-plugin': 2.0.12(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/jest@30.0.0)(@types/node@20.17.19)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)(jest-environment-node@30.3.0)
+ '@rushstack/heft-jest-plugin': 2.0.12(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/jest@30.0.0)(@types/node@20.17.19)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)(jest-environment-node@30.3.0)
'@rushstack/heft-lint-plugin': 1.2.22(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19)
'@rushstack/heft-typescript-plugin': 1.3.17(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19)
'@types/jest': 30.0.0
diff --git a/common/config/subspaces/default/repo-state.json b/common/config/subspaces/default/repo-state.json
index eb492e850ed..e53c1789de1 100644
--- a/common/config/subspaces/default/repo-state.json
+++ b/common/config/subspaces/default/repo-state.json
@@ -1,5 +1,5 @@
// DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush.
{
- "pnpmShrinkwrapHash": "288ee1cccceca305122bc3d4b1eb550efbcb2a0b",
+ "pnpmShrinkwrapHash": "f1240bb4368406615df1628e5fe9369e8d232f6a",
"preferredVersionsHash": "029c99bd6e65c5e1f25e2848340509811ff9753c"
}
diff --git a/rush-plugins/rush-serve-plugin/README.md b/rush-plugins/rush-serve-plugin/README.md
index 11180aecaef..b556ce248c8 100644
--- a/rush-plugins/rush-serve-plugin/README.md
+++ b/rush-plugins/rush-serve-plugin/README.md
@@ -22,6 +22,43 @@ This plugin also provides a web socket server that notifies clients of the build
The recommended way to connect to the web socket is to serve a static HTML page from the serve plugin using the `globalRouting` configuration.
+This package includes the reference `@rushstack/rush-serve-dashboard` web app, which speaks the plugin's WebSocket protocol. Its source lives under `apps/rush-serve-dashboard/`, and its browser-ready assets are copied into this package at `lib-esm/dashboard/`. Because the HTML references `dashboard.js` plus scoped styles under `styles/`, serve the ESM build folder rather than a single file:
+
+```json
+{
+ "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush-serve-plugin-options.schema.json",
+ "phasedCommands": ["start"],
+ "buildStatusWebSocketPath": "/ws",
+ "globalRouting": [
+ {
+ "workspaceRelativeFolder": "rush-plugins/rush-serve-plugin/lib-esm/dashboard",
+ "servePath": "/dashboard",
+ "immutable": false
+ }
+ ]
+}
+```
+
+The Rush server resolves extensionless module requests to emitted `.js` files.
+
+Then open `https://localhost:/dashboard/dashboard.html`.
+
+### Dashboard
+
+The dashboard provides table and dependency-graph views of Rush operations. Use search or the
+failed/warning filter to narrow the current view, select operations to include their dependencies or consumers,
+and open the terminal pane to inspect operation logs. The current view and filter are reflected in the `view`
+and `filter` URL parameters so a dashboard view can be bookmarked.
+
+When connected, the dashboard can execute or abort a build, pause the watcher, change parallelism, invalidate
+operations, close runners, and change operation enabled states. Enabled-state changes default to safe mode;
+unsafe mode enables changes that can leave downstream state inconsistent. The dashboard reconnects
+automatically after an unexpected disconnect. A manual disconnect remains disconnected until the Connect
+button is selected.
+
+Because the WebSocket protocol accepts build-control commands, do not expose the dashboard or socket endpoint
+to untrusted networks. Apply the same authentication and network restrictions used for the served project.
+
To use the socket:
```ts
diff --git a/rush-plugins/rush-serve-plugin/config/heft.json b/rush-plugins/rush-serve-plugin/config/heft.json
index 625490052a1..6f8c256114d 100644
--- a/rush-plugins/rush-serve-plugin/config/heft.json
+++ b/rush-plugins/rush-serve-plugin/config/heft.json
@@ -20,6 +20,23 @@
]
}
}
+ },
+ "copy-dashboard-assets": {
+ "taskPlugin": {
+ "pluginPackage": "@rushstack/heft",
+ "pluginName": "copy-files-plugin",
+ "options": {
+ "copyOperations": [
+ {
+ "sourcePath": "node_modules/@rushstack/rush-serve-dashboard/lib-esm",
+ "destinationFolders": ["lib-commonjs/dashboard", "lib-esm/dashboard"],
+ "fileExtensions": [".js", ".js.map", ".html", ".css"],
+ "excludeGlobs": ["test/**"],
+ "hardlink": true
+ }
+ ]
+ }
+ }
}
}
}
diff --git a/rush-plugins/rush-serve-plugin/package.json b/rush-plugins/rush-serve-plugin/package.json
index 1204d908312..51fcd0ed722 100644
--- a/rush-plugins/rush-serve-plugin/package.json
+++ b/rush-plugins/rush-serve-plugin/package.json
@@ -32,6 +32,7 @@
},
"devDependencies": {
"@rushstack/heft": "workspace:*",
+ "@rushstack/rush-serve-dashboard": "workspace:*",
"eslint": "~9.37.0",
"local-node-rig": "workspace:*",
"@types/compression": "~1.7.2",
diff --git a/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts b/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts
index a135c7aec04..40c4d42b7c5 100644
--- a/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts
+++ b/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts
@@ -195,6 +195,7 @@ export async function phasedCommandHandler(options: IPhasedCommandHandlerOptions
servePath,
express.static(diskPath, {
dotfiles: 'ignore',
+ extensions: ['js'],
immutable: rule.immutable,
index: false,
redirect: false,
diff --git a/rush.json b/rush.json
index 622021e313e..723bcdc8ba3 100644
--- a/rush.json
+++ b/rush.json
@@ -489,6 +489,12 @@
"reviewCategory": "libraries",
"versionPolicyName": "rush"
},
+ {
+ "packageName": "@rushstack/rush-serve-dashboard",
+ "projectFolder": "apps/rush-serve-dashboard",
+ "reviewCategory": "libraries",
+ "shouldPublish": false
+ },
{
"packageName": "@rushstack/trace-import",
"projectFolder": "apps/trace-import",