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 + +
+
+ + + + + + + + +
+
+ +
+
+
+
+ + + +
+
+
+
+ +
+ +
+
+
Terminal Output
+
+ + + + + +
+
+
+
+
+ + + 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",