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..a88f225ef85 --- /dev/null +++ b/apps/rush-serve-dashboard/README.md @@ -0,0 +1,10 @@ +# @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/assets/dashboard.html b/apps/rush-serve-dashboard/assets/dashboard.html new file mode 100644 index 00000000000..6f4cec2d9bf --- /dev/null +++ b/apps/rush-serve-dashboard/assets/dashboard.html @@ -0,0 +1,212 @@ + + + + + + + + Rush Serve Dashboard + + + + + +
+
+ + Disconnected +
+
Rush Serve Dashboard
+
+ + + + + + + + +
+
+
+ + + + +
+
+
+ + +
+
+ +
+
+ Dependency Graph + +
+
+ + + + + + + + +
+
+ +
+
+
+
+ + + +
+
+
+
+ +
+ +
+
+
Terminal Output
+
+ + + + + +
+
+
+
+
+ + diff --git a/apps/rush-serve-dashboard/config/heft.json b/apps/rush-serve-dashboard/config/heft.json new file mode 100644 index 00000000000..2086b83cf1d --- /dev/null +++ b/apps/rush-serve-dashboard/config/heft.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft.schema.json", + "extends": "local-web-rig/profiles/app/config/heft.json" +} 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..df19cbc9db8 --- /dev/null +++ b/apps/rush-serve-dashboard/package.json @@ -0,0 +1,24 @@ +{ + "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" + }, + "dependencies": { + "tslib": "~2.8.1" + }, + "devDependencies": { + "@rushstack/heft": "workspace:*", + "eslint": "~9.37.0", + "local-web-rig": "workspace:*" + }, + "sideEffects": [ + "**/*.css" + ] +} diff --git a/apps/rush-serve-dashboard/src/dashboard.ts b/apps/rush-serve-dashboard/src/dashboard.ts new file mode 100644 index 00000000000..4ef26f4a83e --- /dev/null +++ b/apps/rush-serve-dashboard/src/dashboard.ts @@ -0,0 +1,623 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import globalStyles from './styles/global.module.css'; +import graphStyles from './styles/graphView.module.css'; +import selectionStyles from './styles/selectionBar.module.css'; +import tableStyles from './styles/tableView.module.css'; +import terminalStyles from './styles/terminalPane.module.css'; +import topBarStyles from './styles/topBar.module.css'; +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; +} + +function addClasses(id: string, ...classNames: string[]): void { + document.getElementById(id)?.classList.add(...classNames); +} + +function applyStaticStyles(): void { + document.body.classList.add(globalStyles.dashboard); + addClasses('top-bar', topBarStyles.topBar); + addClasses('overall-status', topBarStyles.overallStatus); + addClasses('status-emoji', topBarStyles.statusEmoji); + addClasses( + 'status-pill', + globalStyles.statusPill, + topBarStyles.statusPill, + globalStyles.statusDisconnected + ); + addClasses('app-title', globalStyles.appTitle); + addClasses('actions', globalStyles.actions, topBarStyles.actions); + addClasses('graph-state', globalStyles.graphState, topBarStyles.graphState); + addClasses('view-controls', globalStyles.viewControls, topBarStyles.viewControls); + addClasses('parallelism-label', globalStyles.flexRow, globalStyles.parallelismLabel); + addClasses('parallelism-input', globalStyles.parallelismInput); + addClasses('search-label', globalStyles.searchLabel); + addClasses('name-search', globalStyles.nameSearch); + addClasses('top-bar-spacer', globalStyles.flexSpacer); + addClasses( + 'connection-form', + globalStyles.flexRow, + globalStyles.connectionForm, + topBarStyles.connectionForm + ); + addClasses('connect-btn', topBarStyles.connectButton); + + const iconButtonIds: string[] = [ + 'play-pause-btn', + 'execute-btn', + 'abort-execution-btn', + 'debug-btn', + 'verbose-btn', + 'select-visible-btn', + 'connect-btn', + 'toggle-terminal-btn', + 'term-clear-btn', + 'term-autoscroll-btn' + ]; + iconButtonIds.forEach((id) => addClasses(id, globalStyles.iconBtn)); + addClasses('abort-execution-btn', globalStyles.stop); + ['debug-btn', 'verbose-btn', 'connect-btn', 'toggle-terminal-btn', 'term-autoscroll-btn'].forEach((id) => + addClasses(id, globalStyles.toggle) + ); + + addClasses('selection-bar', selectionStyles.selectionBar); + addClasses('selection-heading', selectionStyles.selectionHeading); + addClasses('selection-actions', selectionStyles.selectionActions); + const actionButtonIds: string[] = [ + 'clear-selection-btn', + 'invalidate-btn', + 'close-runners-btn', + 'set-enabled-default-btn', + 'set-enabled-ignore-deps-btn', + 'set-enabled-disabled-btn', + 'selection-mode-btn', + 'expand-deps-btn', + 'expand-consumers-btn' + ]; + actionButtonIds.forEach((id) => addClasses(id, globalStyles.action)); + addClasses('clear-selection-btn', selectionStyles.selectionCountButton); + addClasses('selection-mode-btn', globalStyles.toggle); + + addClasses('content-wrap', globalStyles.contentWrap); + addClasses('main', globalStyles.main); + addClasses('left', globalStyles.pane, globalStyles.leftPane); + addClasses('right', globalStyles.pane); + addClasses('operations-table-container', tableStyles.operationsTableContainer); + addClasses('operations-table', tableStyles.operationsTable); + addClasses('table-stats', tableStyles.tableStats); + addClasses('graph-container', graphStyles.graphContainer); + addClasses('phase-pane', graphStyles.phasePane); + addClasses('phase-groups', graphStyles.phaseGroups); + addClasses('graph-wrapper', graphStyles.graphWrapper); + addClasses('graph', graphStyles.graph); + addClasses('graph-legend', graphStyles.graphLegend); + + addClasses('resizer', terminalStyles.resizer); + addClasses('terminal', terminalStyles.terminalContainer); + addClasses('terminal-header', terminalStyles.terminalHeader); + addClasses('terminal-title', terminalStyles.terminalTitle); + addClasses('terminal-controls', terminalStyles.terminalControls); + addClasses('terminal-body', terminalStyles.terminalBody); + addClasses('term-autoscroll', terminalStyles.termAutoscroll); + addClasses('toggle-terminal-btn', terminalStyles.toggleTerminalButton); + addClasses('term-autoscroll-icon', terminalStyles.vertical); +} + +applyStaticStyles(); + +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 | undefined = document.querySelector('svg#edges') ?? undefined; +if (!edgesSvg) { + throw new Error('The graph edges SVG element was not found.'); +} +const graphEl: HTMLElement = document.getElementById('graph') as HTMLElement; +const legendEl: HTMLElement | undefined = document.getElementById('graph-legend') ?? undefined; +const phaseGroupsEl: HTMLElement | undefined = document.getElementById('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..6531c42e5e0 --- /dev/null +++ b/apps/rush-serve-dashboard/src/modules/graphSelection.ts @@ -0,0 +1,246 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import graphStyles from '../styles/graphView.module.css'; + +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(`.${graphStyles.opNode}`)) return; + + dragSelecting = true; + dragStart = _graphPointFromEvent(e); + dragLast = dragStart; + preDragSelection = new Set(options.getSelection()); + graphMarqueeEl = document.createElement('div'); + graphMarqueeEl.className = graphStyles.graphMarquee; + 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..3a1a2f89061 --- /dev/null +++ b/apps/rush-serve-dashboard/src/modules/graphView.ts @@ -0,0 +1,579 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import graphStyles from '../styles/graphView.module.css'; +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 button: HTMLButtonElement = document.createElement('button'); + button.type = 'button'; + button.setAttribute('aria-label', name); + button.className = graphStyles.opNode; + button.dataset.name = name; + button.style.transform = `translate(${x}px, ${y}px)`; + const emojiSpan: HTMLSpanElement = document.createElement('span'); + emojiSpan.className = graphStyles.emoji; + emojiSpan.textContent = options.getStatusEmoji(options.getComputeDisplayStatus(op)); + button.appendChild(emojiSpan); + const enabledSup: HTMLSpanElement = document.createElement('span'); + enabledSup.className = graphStyles.enabledIndicator; + enabledSup.textContent = ''; + button.appendChild(enabledSup); + button.addEventListener('click', (e) => { + e.stopPropagation(); + if (e.metaKey || e.ctrlKey) options.toggleSelect(name); + else options.singleSelect(name); + }); + options.graphEl.appendChild(button); + graphState.nodePositions.set(name, { x, y }); + graphState.nodeElements.set(name, button); + }); + }); + + 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 | undefined = div.getElementsByClassName(graphStyles.emoji)[0]; + if (emojiSpan && (prevStatus !== displayStatus || !emojiSpan.textContent)) { + emojiSpan.textContent = options.getStatusEmoji(displayStatus); + } + const enabledSpan: HTMLSpanElement | undefined = div.getElementsByClassName( + graphStyles.enabledIndicator + )[0] as HTMLSpanElement | undefined; + 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; + const isSelected: boolean = options.getSelection().has(name); + div.setAttribute('aria-pressed', String(isSelected)); + if (isSelected) div.classList.add(graphStyles.selected); + else div.classList.remove(graphStyles.selected); + let activeSpan: HTMLSpanElement | undefined = div.getElementsByClassName( + graphStyles.activeIndicator + )[0] as HTMLSpanElement | undefined; + if (op.isActive) { + if (!activeSpan) { + activeSpan = document.createElement('span'); + activeSpan.className = graphStyles.activeIndicator; + activeSpan.textContent = '⚑'; + div.appendChild(activeSpan); + } + activeSpan.title = 'Active (in-memory state)'; + } else if (activeSpan) { + activeSpan.remove(); + } + let pendingSpan: HTMLSpanElement | undefined = div.getElementsByClassName( + graphStyles.pendingIndicator + )[0] as HTMLSpanElement | undefined; + if (isQueuedNext) { + if (!pendingSpan) { + pendingSpan = document.createElement('span'); + pendingSpan.className = graphStyles.pendingIndicator; + pendingSpan.textContent = 'πŸ•’'; + div.appendChild(pendingSpan); + } + pendingSpan.title = 'Pending changes (iteration queued)'; + } else if (pendingSpan) { + pendingSpan.remove(); + } + div.classList.remove( + graphStyles.notRunning, + graphStyles.filteredOut, + graphStyles.filteredOutSearch, + graphStyles.dashed, + graphStyles.dotted + ); + if (isSearchFiltered) { + div.classList.add(graphStyles.filteredOutSearch); + } else if (isFilteredOut) { + div.classList.add(graphStyles.filteredOut); + } else if (notRunning) { + div.classList.add(graphStyles.notRunning); + } + 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', graphStyles.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(graphStyles.highlight); + else rec.path.classList.remove(graphStyles.highlight); + rec.path.classList.remove( + graphStyles.dashed, + graphStyles.dotted, + graphStyles.filteredOut, + graphStyles.notRunning + ); + rec.path.classList.remove(graphStyles.filteredOutSearch); + 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(graphStyles.highlight)) { + rec.path.classList.add(graphStyles.dim); + } else { + rec.path.classList.remove(graphStyles.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..4ff6f654055 --- /dev/null +++ b/apps/rush-serve-dashboard/src/modules/phaseLegend.ts @@ -0,0 +1,448 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import graphStyles from '../styles/graphView.module.css'; +import { getStatusClassName } from './statusHelpers'; + +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 = graphStyles.phasePaneEmpty; + empty.textContent = 'No phases'; + options.phaseGroupsEl.appendChild(empty); + return; + } + + for (const summary of summaries) { + const group: HTMLDivElement = document.createElement('div'); + group.className = graphStyles.phaseGroup; + + const header: HTMLDivElement = document.createElement('div'); + header.className = graphStyles.phaseHeader; + + const emoji: HTMLSpanElement = document.createElement('span'); + emoji.className = graphStyles.phaseStatusEmoji; + emoji.textContent = options.statusEmoji(summary.status); + + const nameSpan: HTMLSpanElement = document.createElement('span'); + nameSpan.className = graphStyles.phaseName; + 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 = graphStyles.phaseProblems; + + 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 = graphStyles.phaseProblemEmoji; + 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(graphStyles.collapsed); + toggleBtn.textContent = collapsed ? '+' : 'βˆ’'; + toggleBtn.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + + toggleBtn.addEventListener('click', () => { + const isCollapsed: boolean = legendEl.classList.toggle(graphStyles.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(graphStyles.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 = graphStyles.legendColumns; + + const colPrimary: HTMLDivElement = document.createElement('div'); + colPrimary.className = graphStyles.legendCol; + + const primaryHead: HTMLDivElement = document.createElement('div'); + primaryHead.className = graphStyles.legendHeading; + primaryHead.textContent = 'Statuses'; + colPrimary.appendChild(primaryHead); + + for (const status of legendOrder) { + const row: HTMLDivElement = document.createElement('div'); + row.className = graphStyles.legendRow; + + const sample: HTMLSpanElement = document.createElement('span'); + sample.className = graphStyles.legendEmoji; + sample.textContent = options.statusEmoji(status); + sample.style.borderColor = statusColors[status] || '#4b5563'; + + const labelWrap: HTMLDivElement = document.createElement('div'); + labelWrap.className = graphStyles.legendLabelWrap; + 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 = graphStyles.legendRow; + + const unknownSample: HTMLSpanElement = document.createElement('span'); + unknownSample.className = `${graphStyles.legendEmoji} ${getStatusClassName('Unknown')}`; + unknownSample.textContent = '❓'; + unknownSample.style.borderColor = '#4b5563'; + + const unknownLabelWrap: HTMLDivElement = document.createElement('div'); + unknownLabelWrap.className = graphStyles.legendLabelWrap; + + 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 = graphStyles.legendCol; + + const secondaryHead: HTMLDivElement = document.createElement('div'); + secondaryHead.className = graphStyles.legendHeading; + secondaryHead.textContent = 'State Modifiers'; + colSecondary.appendChild(secondaryHead); + + const addModifier = (sampleFactory: () => HTMLElement, label: string, detail?: string): void => { + const row: HTMLDivElement = document.createElement('div'); + row.className = graphStyles.legendRow; + const sample: HTMLElement = sampleFactory(); + + const labelWrap: HTMLDivElement = document.createElement('div'); + labelWrap.className = graphStyles.legendLabelWrap; + 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 = graphStyles.legendEmoji; + 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 = graphStyles.legendEnabledSample; + + 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 = graphStyles.legendEnabledSample; + + 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 = graphStyles.legendEnabledSample; + const sub: HTMLSpanElement = document.createElement('span'); + sub.className = graphStyles.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 = graphStyles.legendSubheading; + 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..59135be4d1b --- /dev/null +++ b/apps/rush-serve-dashboard/src/modules/statusHelpers.ts @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import globalStyles from '../styles/global.module.css'; + +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: '❓' +}; + +const statusClassNames: Record = { + Ready: globalStyles.statusReady, + Waiting: globalStyles.statusWaiting, + Queued: globalStyles.statusQueued, + Executing: globalStyles.statusExecuting, + Success: globalStyles.statusSuccess, + SuccessWithWarning: globalStyles.statusSuccessWithWarning, + Skipped: globalStyles.statusSkipped, + FromCache: globalStyles.statusFromCache, + Failure: globalStyles.statusFailure, + Blocked: globalStyles.statusBlocked, + NoOp: globalStyles.statusNoOp, + Aborted: globalStyles.statusAborted, + Canceled: globalStyles.statusCanceled, + Unspecified: globalStyles.statusUnspecified, + Unknown: globalStyles.statusUnknown, + Disconnected: globalStyles.statusDisconnected +}; + +export function getStatusClassName(status: string | undefined): string { + return (status && statusClassNames[status]) || globalStyles.statusUnknown; +} + +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..0100c0b6d2c --- /dev/null +++ b/apps/rush-serve-dashboard/src/modules/tableView.ts @@ -0,0 +1,274 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import tableStyles from '../styles/tableView.module.css'; +import globalStyles from '../styles/global.module.css'; +import { getStatusClassName } from './statusHelpers'; + +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.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'); + + 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(tableStyles.selected); + + const pkgTd: HTMLTableCellElement = document.createElement('td'); + pkgTd.className = tableStyles.pkgCell; + 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 = tableStyles.pivotCell; + 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(tableStyles.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..11282c72e8f --- /dev/null +++ b/apps/rush-serve-dashboard/src/modules/terminalPane.ts @@ -0,0 +1,210 @@ +// 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'; +import globalStyles from '../styles/global.module.css'; +import terminalStyles from '../styles/terminalPane.module.css'; + +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 = `${terminalStyles.termChunk} ${ + kind === 'stderr' ? terminalStyles.stderr : terminalStyles.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(terminalStyles.hidden)) { + terminalEl.classList.add(terminalStyles.termFlash); + setTimeout(() => terminalEl.classList.remove(terminalStyles.termFlash), 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(terminalStyles.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(terminalStyles.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(terminalStyles.hidden); + + if (currentlyHidden) { + terminalWithState.classList.remove(terminalStyles.hidden); + if (resizerEl) resizerEl.classList.remove(terminalStyles.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(globalStyles.active); + } else { + try { + terminalWithState._savedWidth = terminalWithState.getBoundingClientRect().width; + } catch { + // no-op + } + + terminalWithState.classList.add(terminalStyles.hidden); + if (resizerEl) { + resizerEl.classList.add(terminalStyles.hidden); + resizerEl.tabIndex = -1; + } + + terminalWithState.style.width = ''; + terminalWithState.style.flex = ''; + toggleTerminalBtn.setAttribute('aria-pressed', 'false'); + toggleTerminalBtn.classList.remove(globalStyles.active); + } + }); + + const isVisible: boolean = !terminalWithState.classList.contains(terminalStyles.hidden); + toggleTerminalBtn.setAttribute('aria-pressed', isVisible ? 'true' : 'false'); + if (isVisible) toggleTerminalBtn.classList.add(globalStyles.active); + else toggleTerminalBtn.classList.remove(globalStyles.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..92611a5311e --- /dev/null +++ b/apps/rush-serve-dashboard/src/modules/topBar.ts @@ -0,0 +1,193 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import globalStyles from '../styles/global.module.css'; +import topBarStyles from '../styles/topBar.module.css'; +import { getStatusClassName } from './statusHelpers'; + +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 = `${globalStyles.statusPill} ${topBarStyles.statusPill} ${getStatusClassName('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 = `${globalStyles.statusPill} ${topBarStyles.statusPill} ${getStatusClassName(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(globalStyles.active); + else debugBtn.classList.remove(globalStyles.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(globalStyles.active); + else verboseBtn.classList.remove(globalStyles.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(globalStyles.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(globalStyles.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(globalStyles.queued); + executeBtn.title = 'Run once (changes detected)'; + executeBtn.setAttribute('aria-label', 'Run once (changes detected)'); + } else { + executeBtn.classList.remove(globalStyles.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/global.module.css b/apps/rush-serve-dashboard/src/styles/global.module.css new file mode 100644 index 00000000000..c8d248f46b6 --- /dev/null +++ b/apps/rush-serve-dashboard/src/styles/global.module.css @@ -0,0 +1,425 @@ +/* Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. */ +/* See LICENSE in the project root for license information. */ + +:global(: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-white: 255, 255, 255; + --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; +} +:global(html), +:global(body) { + height: 100%; + margin: 0; + background: var(--bg); + color: var(--text); +} +.dashboard { + display: flex; + flex-direction: column; +} +.dashboard h1 { + font-size: 1.2rem; + margin: 0 0 0.5rem; +} +.dashboard a { + color: var(--accent); +} +.dashboard code { + background: var(--panel-alt); + padding: 2px 4px; + border-radius: 4px; +} +.flexSpacer { + flex: 1 1 auto; +} +.appTitle { + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.5px; + min-width: 180px; +} +.actions, +.graphState { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + align-items: center; +} +.parallelismLabel { + font-size: 0.65rem; + align-items: baseline; + gap: 4px; +} +.searchLabel { + display: flex; + align-items: center; + gap: 4px; +} +.parallelismInput, +.nameSearch { + background: var(--panel-alt); + border: 1px solid var(--border); + color: var(--text); + border-radius: var(--radius); + padding: 2px 4px; + font-size: 0.7rem; +} +.parallelismInput { + width: 60px; +} +.nameSearch { + min-width: 140px; +} +.connectionForm { + gap: 0.4rem; +} +.contentWrap { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} +.main { + flex: 1; + display: flex; + min-height: 0; +} +.viewControls { + display: flex; + gap: 0.75rem; + align-items: center; + font-size: 0.7rem; +} +.viewControls label { + display: flex; + gap: 0.25rem; + align-items: center; + cursor: pointer; +} +.dashboard select { + background: var(--panel-alt); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2px 4px; + font-size: 0.7rem; +} +.dashboard select:disabled { + opacity: 0.6; +} +.connectionForm input { + background: var(--panel-alt); + border: 1px solid var(--border); + color: var(--text); + padding: 0.35rem 0.5rem; + border-radius: var(--radius); +} +.dashboard button:not(:disabled) { + cursor: pointer; +} +.dashboard button:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.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; +} +.action:hover:not(:disabled) { + background: #2d3744; +} +.primary, +.iconBtn.primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +.primary:hover:not(:disabled), +.iconBtn.primary:hover:not(:disabled) { + background: var(--accent-hover); +} +.iconBtn { + 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; +} +.iconBtn:hover:not(:disabled), +.iconBtn.stop:hover:not(:disabled) { + filter: brightness(1.15); +} +.iconBtn.stop { + color: var(--danger); +} +.iconBtn.playing { + color: var(--accent); +} +.iconBtn.playing:hover:not(:disabled) { + color: var(--accent-hover); + filter: none; +} +.iconBtn:disabled { + opacity: 0.45; +} +.iconBtn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 4px; +} +.iconBtn.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; +} +.iconBtn.toggle { + color: var(--muted); +} +.iconBtn.toggle:hover:not(.active):not(:disabled) { + color: var(--text); +} +.iconBtn.toggle.active, +.iconBtn.active { + color: var(--accent); +} +.iconBtn.toggle.active:hover:not(:disabled) { + color: var(--accent-hover); +} +@keyframes queuedPulse { + 0%, + 100% { + opacity: 0.2; + } + 50% { + opacity: 0.9; + } +} +.dashboard :global(.codicon) { + font-size: 18px; + line-height: 1; +} +.iconBtn :global(.codicon) { + pointer-events: none; +} +.iconBtn.stop :global(.codicon) { + font-size: 17px; +} +.danger { + background: var(--danger); + border-color: var(--danger); + color: #fff; +} +.danger:hover:not(:disabled) { + filter: brightness(1.1); +} +.pane { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} +.leftPane { + 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; +} +.statusPill { + 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; +} +.statusReady, +.statusUnspecified, +.statusUnknown { + background: var(--status-ready); +} +.statusWaiting { + background: var(--status-waiting); +} +.statusQueued { + background: var(--status-queued); +} +.statusExecuting { + background: var(--warn); + color: #000; +} +.statusSuccess { + background: var(--success); + color: #000; +} +.statusSuccessWithWarning { + background: var(--status-success-warning); + color: #000; +} +.statusFromCache { + background: var(--status-from-cache); + color: #000; +} +.statusFailure { + background: var(--danger); +} +.statusBlocked, +.statusSkipped { + background: var(--status-skipped); +} +.statusNoOp { + background: var(--status-noop); +} +.statusAborted, +.statusCanceled { + background: var(--status-aborted); +} +.statusUnknown { + opacity: 0.85; +} +.statusDisconnected { + 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.localOnly { + background: var(--warn); + color: #000; +} +.badge.notRunning { + background: #1e293b; + border: 1px dashed #475569; +} +.bottomBar { + 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; +} +.flexRow { + display: flex; + gap: 0.4rem; + align-items: center; +} +.dashboard input[type='number'] { + width: 70px; +} +@media (max-width: 1200px) { + .main { + flex-direction: column; + } + .leftPane { + border-right: none; + border-bottom: 1px solid var(--border); + } +} +.dashboard * { + scrollbar-color: var(--scroll-thumb) var(--scroll-track); + scrollbar-width: thin; +} +.dashboard *::-webkit-scrollbar { + width: 10px; + height: 10px; +} +.dashboard *::-webkit-scrollbar-track { + background: var(--scroll-track); +} +.dashboard *::-webkit-scrollbar-thumb { + background: var(--scroll-thumb); + border: 2px solid var(--scroll-track); + border-radius: 8px; +} +.dashboard *::-webkit-scrollbar-thumb:hover { + background: var(--scroll-thumb-hover); +} +.dashboard *::-webkit-scrollbar-corner { + background: var(--scroll-track); +} diff --git a/apps/rush-serve-dashboard/src/styles/graphView.module.css b/apps/rush-serve-dashboard/src/styles/graphView.module.css new file mode 100644 index 00000000000..0aaa61d566f --- /dev/null +++ b/apps/rush-serve-dashboard/src/styles/graphView.module.css @@ -0,0 +1,451 @@ +/* Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. */ +/* See LICENSE in the project root for license information. */ + +.graphContainer { + 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); +} + +.phasePane { + 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; +} + +.phasePane h3 { + margin: 0 0 0.4rem; + font-size: 0.68rem; + letter-spacing: 0.5px; + text-transform: uppercase; + opacity: 0.8; +} + +.phaseGroup { + 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; +} + +.phaseGroups { + display: flex; + flex-direction: column; + gap: 0.6rem; + height: calc(100% - 1.2rem); + overflow: hidden; +} + +.phaseGroups > .phaseGroup { + flex: 1 1 0; +} + +.phaseHeader { + 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; +} + +.phaseStatusEmoji { + font-size: 0.8rem; + line-height: 1; +} + +.phaseName { + flex: 1; +} + +.phaseProblems { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; + overflow-y: auto; + flex: 1 1 auto; + min-height: 0; +} + +.phaseProblems li { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.phaseProblems a { + color: var(--accent); + text-decoration: none; +} + +.phaseProblems a:hover { + text-decoration: underline; +} + +.phaseProblemEmoji { + font-size: 0.75rem; +} + +.phasePaneEmpty { + opacity: 0.6; + font-style: italic; + font-size: 0.6rem; +} + +.graphWrapper { + flex: 1; + min-height: 0; + position: relative; +} + +.graphLegend { + 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; +} + +.graphLegend .legendColumns { + display: flex; + gap: 10px; + align-items: flex-start; +} + +.graphLegend .legendCol:first-child { + flex: 0 0 130px; +} + +.graphLegend .legendCol:last-child { + flex: 1 1 auto; +} + +.graphLegend.collapsed { + width: auto; + max-width: none; + padding: 4px 6px; +} + +.graphLegend.collapsed .legendColumns { + display: none; +} + +.graphLegend .legendCol { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.graphLegend .legendHeading { + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; + opacity: 0.75; + font-size: 0.58rem; + margin: 0 0 4px; +} + +.graphLegend .legendRow { + display: flex; + align-items: center; + gap: 5px; + min-width: 0; +} + +.graphLegend .legendLabelWrap { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.graphLegend .legendRow small { + font-size: 0.5rem; + opacity: 0.7; +} + +.graphLegend .legendSubheading { + margin: 4px 0 2px; + font-size: 0.52rem; + text-transform: uppercase; + letter-spacing: 0.5px; + opacity: 0.65; + font-weight: 600; +} + +.graphLegend 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; +} + +.legendEmoji { + 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; +} + +.legendEnabledSample { + 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; +} + +.legendEnabledSample .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; +} + +.legendLabel { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.graph svg { + position: absolute; + top: 0; + left: 0; + overflow: visible; + pointer-events: none; +} + +.graphMarquee { + 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; +} + +.opNode { + position: absolute; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + font-size: 14px; + font-family: inherit; + 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; +} + +.opNode .enabledIndicator { + 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; +} + +.opNode.selected { + box-shadow: 0 0 0 2px var(--accent); +} + +.opNode:hover { + transform: scale(1.08); +} + +.opNode .activeIndicator { + 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)); +} + +.opNode .pendingIndicator { + 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)); +} + +.opNode.filteredOut .activeIndicator, +.opNode.filteredOutSearch .activeIndicator, +.opNode.notRunning .activeIndicator, +.opNode.filteredOut.notRunning .activeIndicator { + opacity: 1 !important; +} + +.opNode .name { + font-weight: 600; +} + +.opNode .pkg { + color: var(--muted); + font-size: 0.6rem; +} + +.opNode .badges { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.opNode .enabledIndicator { + opacity: 1; +} + +.opNode.notRunning .enabledIndicator { + opacity: 0.5; +} + +.opNode.filteredOut .enabledIndicator { + opacity: 0.25; +} + +.opNode.filteredOutSearch .enabledIndicator { + opacity: 0.15; +} + +.opNode.filteredOut.notRunning .enabledIndicator { + opacity: 0.25; +} + +.opNode .emoji { + transition: opacity 0.15s ease; +} + +.opNode.filteredOut .emoji { + opacity: 0.25; +} + +.opNode.filteredOutSearch .emoji { + opacity: 0.15; +} + +.opNode.notRunning .emoji { + opacity: 0.5; +} + +.opNode.filteredOut.notRunning .emoji { + opacity: 0.25; +} + +.opNode.dashed { + border-style: dashed; +} + +.opNode.dotted { + border-style: dotted; +} + +.edge { + stroke-width: 1.2; + fill: none; +} + +.edge.dashed { + stroke-dasharray: 5 4; +} + +.edge.dotted { + stroke-dasharray: 2 4; +} + +.edge.filteredOut { + opacity: 0.35; +} + +.edge.filteredOutSearch { + opacity: 0.2; +} + +.edge.highlight { + stroke: var(--accent); + stroke-width: 2; +} + +.edge.dim { + opacity: 0.28; + filter: brightness(0.8); +} + +.edge.notRunning { + opacity: 0.55; +} diff --git a/apps/rush-serve-dashboard/src/styles/selectionBar.module.css b/apps/rush-serve-dashboard/src/styles/selectionBar.module.css new file mode 100644 index 00000000000..8d2250f3445 --- /dev/null +++ b/apps/rush-serve-dashboard/src/styles/selectionBar.module.css @@ -0,0 +1,45 @@ +/* Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. */ +/* See LICENSE in the project root for license information. */ + +.selectionHeading { + font-weight: 600; + letter-spacing: 0.5px; + font-size: 0.7rem; + display: flex; + align-items: center; + gap: 0.5rem; +} +.selectionActions { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; + align-items: center; +} +.selectionBar { + 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; +} +.selectionActions button { + font-size: 0.65rem; +} +.selectionBar button { + line-height: 1.1; + height: 30px; + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.65rem; +} +.selectionCountButton[disabled] { + opacity: 0.6; + cursor: default; +} +.selectionBar[aria-hidden='true'] { + display: none; +} diff --git a/apps/rush-serve-dashboard/src/styles/tableView.module.css b/apps/rush-serve-dashboard/src/styles/tableView.module.css new file mode 100644 index 00000000000..3eff12a282a --- /dev/null +++ b/apps/rush-serve-dashboard/src/styles/tableView.module.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. */ + +.operationsTableContainer { + flex: 1; + min-height: 0; + overflow: auto; +} + +.operationsTable { + width: 100%; + border-collapse: collapse; + font-size: 0.75rem; +} + +.operationsTable thead { + position: sticky; + top: 0; + background: var(--panel-alt); + z-index: 2; +} + +.operationsTable th, +.operationsTable td { + padding: 0.35rem 0.5rem; + border-bottom: 1px solid var(--border); + text-align: left; + vertical-align: top; +} + +.operationsTable tbody tr { + cursor: pointer; +} + +.operationsTable tbody tr.selected { + background: rgba(var(--rgb-accent), 0.15); +} + +.operationsTable tbody tr:hover { + background: rgba(var(--rgb-white), 0.05); +} + +.operationsTable td.pivotCell.selected { + background: rgba(var(--rgb-accent), 0.22); +} + +.operationsTable tr.selected > td.pkgCell { + background: rgba(var(--rgb-accent), 0.18); +} + +/* Prevent brief native text highlight flash during selection interactions */ +.operationsTable td.pivotCell, +.operationsTable td.pkgCell { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; +} + +.operationsTable th.sortable { + user-select: none; +} + +.operationsTable th.sortable span.sortIndicator { + opacity: 0.5; + margin-left: 4px; +} + +.statusRow { + display: flex; + gap: 4px; + align-items: center; + flex-wrap: wrap; +} + +.statusRow .badges { + display: flex; + gap: 4px; + flex-wrap: wrap; + margin-left: 4px; +} + +.operationsTable td.statusCell { + white-space: nowrap; +} + +.operationsTable td.statusCell .statusPill { + margin-left: 4px; +} + +.pivotEnabled { + margin-left: 4px; + font-size: 0.75rem; +} + +.pivotActive { + margin-left: 4px; + font-size: 0.75rem; + position: relative; + top: 1px; +} + +.tableStats { + 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/terminalPane.module.css b/apps/rush-serve-dashboard/src/styles/terminalPane.module.css new file mode 100644 index 00000000000..24908205c54 --- /dev/null +++ b/apps/rush-serve-dashboard/src/styles/terminalPane.module.css @@ -0,0 +1,92 @@ +/* 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); +} +.termAutoscroll { + display: none; +} +.terminalContainer { + 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; +} +.terminalHeader { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 6px 8px; + border-bottom: 1px solid var(--border); +} +.terminalTitle { + font-weight: 600; + font-size: 0.8rem; +} +.terminalControls { + margin-left: auto; + display: flex; + gap: 6px; + align-items: center; +} +.terminalContainer.hidden, +.resizer.hidden { + display: none !important; +} +.terminalBody { + 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; +} +.termChunk.stdout { + color: var(--text); +} +.termChunk.stderr { + color: var(--danger); +} +.terminalControls button { + width: auto; + height: auto; + padding: 4px 8px; +} +.terminalContainer.termFlash .terminalHeader { + animation: termFlash 300ms ease-in-out; +} +.toggleTerminalButton[aria-pressed='true'], +.toggleTerminalButton[aria-pressed='true'] :global(.codicon) { + color: var(--accent) !important; + fill: var(--accent) !important; +} +.vertical { + display: inline-block; + transform: rotate(90deg); +} +@keyframes termFlash { + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); + } + 30% { + box-shadow: 0 0 8px 2px rgba(59, 130, 246, 0.18); + } +} diff --git a/apps/rush-serve-dashboard/src/styles/topBar.module.css b/apps/rush-serve-dashboard/src/styles/topBar.module.css new file mode 100644 index 00000000000..e60eb9bd759 --- /dev/null +++ b/apps/rush-serve-dashboard/src/styles/topBar.module.css @@ -0,0 +1,95 @@ +/* Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. */ +/* See LICENSE in the project root for license information. */ + +.topBar { + 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; +} +.topBar > * { + display: flex; + align-items: baseline; +} +.topBar button { + width: auto; + height: auto; + padding: 0 4px; + font-size: 0.8rem; + line-height: 1.05; + align-items: baseline; +} +.topBar button :global(.codicon), +.topBar button span[aria-hidden='true'] { + position: relative; + top: 2px; + line-height: 1; +} +.actions, +.graphState, +.viewControls { + align-items: baseline; +} +.overallStatus { + display: flex; + align-items: center; +} +.statusEmoji { + position: relative; + top: 1px; + font-size: 1rem; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; +} +.statusPill { + padding: 2px 10px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 600; + line-height: 1.05; +} +.connectionForm .connectButton[data-state='connected'] { + color: var(--danger); +} +.connectionForm .connectButton[data-state='disconnected'] { + color: var(--accent); +} +.topBar input[type='number']::-webkit-inner-spin-button, +.topBar input[type='number']::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} +.topBar 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; +} +.topBar 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..27d3559bbc3 --- /dev/null +++ b/apps/rush-serve-dashboard/src/test/actionWiring.test.ts @@ -0,0 +1,189 @@ +// 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 globalStyles from '../styles/global.module.css'; +import { + computeWsUrl, + overallStatusText, + setConnected, + showConnectingStatus, + updateManagerState, + updateStatusPill, + type ITopBarRefs +} from '../modules/topBar'; + +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); + }); +}); + +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(globalStyles.statusSuccess)).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(globalStyles.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..7721c618b33 --- /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..9cae50407dc --- /dev/null +++ b/apps/rush-serve-dashboard/src/test/graphSelection.test.ts @@ -0,0 +1,73 @@ +// 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'; +import graphStyles from '../styles/graphView.module.css'; + +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(`.${graphStyles.graphMarquee}`)).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..a14815965a1 --- /dev/null +++ b/apps/rush-serve-dashboard/src/test/graphView.test.ts @@ -0,0 +1,75 @@ +// 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'; +import graphStyles from '../styles/graphView.module.css'; + +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 | undefined = + document.querySelector('svg#edges') ?? undefined; + if (!edgesSvg) { + throw new Error('The graph edges SVG test fixture was not found.'); + } + 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(`.${graphStyles.activeIndicator}`) + ).not.toBeNull(); + expect( + graphState.nodeElements.get('test')?.querySelector(`.${graphStyles.pendingIndicator}`) + ).not.toBeNull(); + expect( + graphState.nodeElements.get('test')?.querySelector(`.${graphStyles.enabledIndicator}`)?.textContent + ).toBe('πŸ”΄'); + expect(renderPhaseLegend).toHaveBeenCalled(); + + const compileNode: HTMLButtonElement | undefined = graphState.nodeElements.get('compile'); + expect(compileNode).toMatchObject({ type: 'button', tabIndex: 0 }); + expect(compileNode?.getAttribute('aria-label')).toBe('compile'); + expect(compileNode?.getAttribute('aria-pressed')).toBe('true'); + compileNode?.focus(); + expect(document.activeElement).toBe(compileNode); + + compileNode?.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..2192a3d2178 --- /dev/null +++ b/apps/rush-serve-dashboard/src/test/outputPanels.test.ts @@ -0,0 +1,102 @@ +// 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'; +import graphStyles from '../styles/graphView.module.css'; +import terminalStyles from '../styles/terminalPane.module.css'; + +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(graphStyles.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(`.${terminalStyles.termChunk}`) as HTMLElement; + expect(chunk.textContent).toBe('failed'); + expect(chunk.classList.contains(terminalStyles.stderr)).toBe(true); + expect(chunk.style.color).toBe('rgb(170, 0, 0)'); + expect(terminalEl.classList.contains(terminalStyles.termFlash)).toBe(true); + jest.advanceTimersByTime(350); + expect(terminalEl.classList.contains(terminalStyles.termFlash)).toBe(false); + + document.getElementById('autoscroll')?.click(); + expect((document.getElementById('autoscroll-checkbox') as HTMLInputElement).checked).toBe(false); + document.getElementById('toggle')?.click(); + expect(terminalEl.classList.contains(terminalStyles.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..afae8a9ecde --- /dev/null +++ b/apps/rush-serve-dashboard/src/test/tableView.test.ts @@ -0,0 +1,52 @@ +// 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'; +import tableStyles from '../styles/tableView.module.css'; + +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.${tableStyles.pivotCell}[title="a-build"]` + ) as HTMLElement; + firstOperationCell.click(); + expect(selection).toEqual(new Set(['a-build'])); + + const firstPackageCell = document.querySelector(`.${tableStyles.pkgCell}`) as HTMLElement; + firstPackageCell.click(); + expect(selection).toEqual(new Set(['a-build', 'a-test'])); + expect(onSelectionMutated).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/rush-serve-dashboard/src/test/viewBar.test.ts b/apps/rush-serve-dashboard/src/test/viewBar.test.ts new file mode 100644 index 00000000000..3283f60dd51 --- /dev/null +++ b/apps/rush-serve-dashboard/src/test/viewBar.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { wireViewBar } from '../modules/viewBar'; +import type { DashboardFilter, DashboardView } from '../modules/urlState'; + +describe(wireViewBar.name, () => { + beforeEach(() => { + document.body.innerHTML = ` + + + + +
+ `; + window.history.replaceState(null, '', '/dashboard'); + }); + + it('initializes controls, panel visibility, and URL state', () => { + wireViewBar({ + getView: () => 'graph', + setView: jest.fn(), + getFilter: () => 'failed-warn', + setFilter: jest.fn(), + setSearchQuery: jest.fn(), + markGraphDirty: jest.fn(), + render: jest.fn() + }); + + expect(document.querySelector('input[value="graph"]')?.checked).toBe(true); + expect(document.querySelector('#filter-select')?.value).toBe('failed-warn'); + expect(document.getElementById('left')?.style.display).toBe('none'); + expect(document.getElementById('right')?.style.display).toBe(''); + expect(window.location.search).toBe('?view=graph&filter=failed-warn'); + }); + + it('updates state and renders when view, filter, and search controls change', () => { + let view: DashboardView = 'table'; + let filter: DashboardFilter = 'all'; + const setSearchQuery = jest.fn(); + const markGraphDirty = jest.fn(); + const render = jest.fn(); + wireViewBar({ + getView: () => view, + setView: (next: DashboardView) => (view = next), + getFilter: () => filter, + setFilter: (next: DashboardFilter) => (filter = next), + setSearchQuery, + markGraphDirty, + render + }); + + const graphRadio: HTMLInputElement = document.querySelector( + 'input[value="graph"]' + ) as HTMLInputElement; + graphRadio.checked = true; + graphRadio.dispatchEvent(new Event('change')); + + const filterSelect: HTMLSelectElement = document.getElementById('filter-select') as HTMLSelectElement; + filterSelect.value = 'failed-warn'; + filterSelect.dispatchEvent(new Event('change')); + + const searchInput: HTMLInputElement = document.getElementById('name-search') as HTMLInputElement; + searchInput.value = 'build'; + searchInput.dispatchEvent(new Event('input')); + + expect(view).toBe('graph'); + expect(filter).toBe('failed-warn'); + expect(setSearchQuery).toHaveBeenCalledWith('build'); + expect(markGraphDirty).toHaveBeenCalledTimes(2); + expect(render).toHaveBeenCalledTimes(3); + expect(document.getElementById('left')?.style.display).toBe('none'); + expect(document.getElementById('right')?.style.display).toBe(''); + expect(window.location.search).toBe('?view=graph&filter=failed-warn'); + }); +}); 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 7eac741275b..a761addba56 100644 --- a/common/config/rush/browser-approved-packages.json +++ b/common/config/rush/browser-approved-packages.json @@ -46,6 +46,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 f986d675fe8..4ec1b48ee88 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -453,6 +453,22 @@ importers: specifier: ~5.8.2 version: 5.8.2 + ../../../apps/rush-serve-dashboard: + dependencies: + tslib: + specifier: ~2.8.1 + version: 2.8.1 + 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': @@ -3197,7 +3213,7 @@ importers: dependencies: '@jest/core': specifier: ~30.3.0 - version: 30.3.0(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)) + version: 30.3.0 '@jest/reporters': specifier: ~30.3.0 version: 30.3.0 @@ -3215,7 +3231,7 @@ importers: version: link:../../libraries/terminal jest-config: specifier: ~30.3.0 - version: 30.3.0(@types/node@20.17.19)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)) + version: 30.3.0(@types/node@20.17.19) jest-resolve: specifier: ~30.3.0 version: 30.3.0 @@ -3630,7 +3646,7 @@ importers: version: 2.4.0 webpack-dev-server: specifier: ^5.1.0 - version: 5.2.3(@types/webpack@4.41.32)(webpack@5.105.4) + version: 5.2.3(webpack@5.105.4) devDependencies: '@rushstack/heft': specifier: workspace:* @@ -4586,7 +4602,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)(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)(jest-environment-jsdom@30.3.0) '@types/jest': specifier: 30.0.0 version: 30.0.0 @@ -4777,7 +4793,7 @@ importers: version: 5.8.2 url-loader: specifier: ~4.1.1 - version: 4.1.1(file-loader@6.2.0(webpack@5.105.4))(webpack@5.105.4) + version: 4.1.1(webpack@5.105.4) webpack: specifier: ~5.105.2 version: 5.105.4 @@ -5177,6 +5193,12 @@ importers: '@rushstack/heft': specifier: workspace:* version: link:../../apps/heft + '@rushstack/heft-webpack5-plugin': + specifier: workspace:* + version: link:../../heft-plugins/heft-webpack5-plugin + '@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) @@ -5192,9 +5214,15 @@ importers: eslint: specifier: ~9.37.0 version: 9.37.0 + html-webpack-plugin: + specifier: ~5.5.0 + version: 5.5.4(webpack@5.105.4) local-node-rig: specifier: workspace:* version: link:../../rigs/local-node-rig + webpack: + specifier: ~5.105.2 + version: 5.105.4 ../../../vscode-extensions/debug-certificate-manager-vscode-extension: dependencies: @@ -22895,7 +22923,7 @@ snapshots: - supports-color - ts-node - '@jest/core@30.3.0(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0))': + '@jest/core@30.3.0': dependencies: '@jest/console': 30.3.0 '@jest/pattern': 30.0.1 @@ -22910,7 +22938,7 @@ snapshots: exit-x: 0.2.2 graceful-fs: 4.2.11 jest-changed-files: 30.3.0 - jest-config: 30.3.0(@types/node@22.9.3)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)) + jest-config: 30.3.0(@types/node@22.9.3) jest-haste-map: 30.3.0 jest-message-util: 30.3.0 jest-regex-util: 30.0.1 @@ -24645,16 +24673,16 @@ 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)(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)(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/core': 30.3.0 '@jest/reporters': 30.3.0 '@jest/transform': 30.3.0 '@rushstack/heft': 1.2.22(@types/node@20.17.19) '@rushstack/heft-config-file': 0.20.12(@types/node@20.17.19) '@rushstack/node-core-library': 5.23.3(@types/node@20.17.19) '@rushstack/terminal': 0.24.2(@types/node@20.17.19) - jest-config: 30.3.0(@types/node@20.17.19)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)) + jest-config: 30.3.0(@types/node@20.17.19) jest-resolve: 30.3.0 jest-snapshot: 30.3.0 optionalDependencies: @@ -24678,13 +24706,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)(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)(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)(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)(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 @@ -29691,6 +29719,8 @@ snapshots: dedent@0.7.0: {} + dedent@1.7.2: {} + dedent@1.7.2(babel-plugin-macros@3.1.0): optionalDependencies: babel-plugin-macros: 3.1.0 @@ -30235,14 +30265,6 @@ snapshots: transitivePeerDependencies: - supports-color - esbuild-register@3.6.0(esbuild@0.28.0): - dependencies: - debug: 4.4.3(supports-color@8.1.1) - esbuild: 0.28.0 - transitivePeerDependencies: - - supports-color - optional: true - esbuild-runner@2.2.2(esbuild@0.14.54): dependencies: esbuild: 0.14.54 @@ -31404,13 +31426,6 @@ snapshots: schema-utils: 3.3.0 webpack: 4.47.0 - file-loader@6.2.0(webpack@5.105.4): - dependencies: - loader-utils: 2.0.4 - schema-utils: 3.3.0 - webpack: 5.105.4 - optional: true - file-system-cache@1.1.0: dependencies: fs-extra: 10.1.0 @@ -32798,7 +32813,7 @@ snapshots: - babel-plugin-macros - supports-color - jest-circus@30.3.0(babel-plugin-macros@3.1.0): + jest-circus@30.3.0: dependencies: '@jest/environment': 30.3.0 '@jest/expect': 30.3.0 @@ -32807,7 +32822,7 @@ snapshots: '@types/node': 22.9.3 chalk: 4.1.2 co: 4.6.0 - dedent: 1.7.2(babel-plugin-macros@3.1.0) + dedent: 1.7.2 is-generator-fn: 2.1.0 jest-each: 30.3.0 jest-matcher-utils: 30.3.0 @@ -32903,7 +32918,7 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@30.3.0(@types/node@20.17.19)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)): + jest-config@30.3.0(@types/node@20.17.19): dependencies: '@babel/core': 7.29.0 '@jest/get-type': 30.1.0 @@ -32916,7 +32931,7 @@ snapshots: deepmerge: 4.3.1 glob: 10.5.0 graceful-fs: 4.2.11 - jest-circus: 30.3.0(babel-plugin-macros@3.1.0) + jest-circus: 30.3.0 jest-docblock: 30.2.0 jest-environment-node: 30.3.0 jest-regex-util: 30.0.1 @@ -32930,12 +32945,11 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 20.17.19 - esbuild-register: 3.6.0(esbuild@0.28.0) transitivePeerDependencies: - babel-plugin-macros - supports-color - jest-config@30.3.0(@types/node@22.9.3)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)): + jest-config@30.3.0(@types/node@22.9.3): dependencies: '@babel/core': 7.29.0 '@jest/get-type': 30.1.0 @@ -32948,7 +32962,7 @@ snapshots: deepmerge: 4.3.1 glob: 10.5.0 graceful-fs: 4.2.11 - jest-circus: 30.3.0(babel-plugin-macros@3.1.0) + jest-circus: 30.3.0 jest-docblock: 30.2.0 jest-environment-node: 30.3.0 jest-regex-util: 30.0.1 @@ -32962,7 +32976,6 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 22.9.3 - esbuild-register: 3.6.0(esbuild@0.28.0) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -37714,14 +37727,12 @@ snapshots: optionalDependencies: file-loader: 6.2.0(webpack@4.47.0) - url-loader@4.1.1(file-loader@6.2.0(webpack@5.105.4))(webpack@5.105.4): + url-loader@4.1.1(webpack@5.105.4): dependencies: loader-utils: 2.0.4 mime-types: 2.1.35 schema-utils: 3.3.0 webpack: 5.105.4 - optionalDependencies: - file-loader: 6.2.0(webpack@5.105.4) url@0.10.3: dependencies: @@ -37950,6 +37961,17 @@ snapshots: '@types/webpack': 4.41.32 webpack: 5.105.4 + webpack-dev-middleware@7.4.5(webpack@5.105.4): + dependencies: + colorette: 2.0.20 + memfs: 4.57.1 + mime-types: 3.0.2 + on-finished: 2.4.1 + range-parser: 1.2.1 + schema-utils: 4.3.3 + optionalDependencies: + webpack: 5.105.4 + webpack-dev-server@4.9.3(@types/webpack@4.41.32)(webpack@4.47.0): dependencies: '@types/bonjour': 3.5.13 @@ -38033,7 +38055,7 @@ snapshots: - utf-8-validate optional: true - webpack-dev-server@5.2.3(@types/webpack@4.41.32)(webpack@5.105.4): + webpack-dev-server@5.2.3(webpack@5.105.4): dependencies: '@types/bonjour': 3.5.13 '@types/connect-history-api-fallback': 1.5.4 @@ -38062,10 +38084,9 @@ snapshots: serve-index: 1.9.2 sockjs: 0.3.24 spdy: 4.0.2 - webpack-dev-middleware: 7.4.5(@types/webpack@4.41.32)(webpack@5.105.4) + webpack-dev-middleware: 7.4.5(webpack@5.105.4) ws: 8.21.0 optionalDependencies: - '@types/webpack': 4.41.32 webpack: 5.105.4 transitivePeerDependencies: - bufferutil diff --git a/common/config/subspaces/default/repo-state.json b/common/config/subspaces/default/repo-state.json index d24b50df380..61a8682c971 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": "121b6455d44723fc7bdb38b7d038aae935a28bd1", + "pnpmShrinkwrapHash": "0cdaaac7c5ac76a646450777edcb5277afddf107", "preferredVersionsHash": "029c99bd6e65c5e1f25e2848340509811ff9753c" } diff --git a/rush-plugins/rush-serve-plugin/README.md b/rush-plugins/rush-serve-plugin/README.md index 11180aecaef..8a9bfe837a4 100644 --- a/rush-plugins/rush-serve-plugin/README.md +++ b/rush-plugins/rush-serve-plugin/README.md @@ -22,6 +22,38 @@ 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 JavaScript and CSS bundles are generated in this package at `lib-esm/dashboard/`. 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 + } + ] +} +``` + +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..d440396ec4e 100644 --- a/rush-plugins/rush-serve-plugin/config/heft.json +++ b/rush-plugins/rush-serve-plugin/config/heft.json @@ -20,6 +20,12 @@ ] } } + }, + "webpack": { + "taskDependencies": ["typescript"], + "taskPlugin": { + "pluginPackage": "@rushstack/heft-webpack5-plugin" + } } } } diff --git a/rush-plugins/rush-serve-plugin/package.json b/rush-plugins/rush-serve-plugin/package.json index 1204d908312..a4b1cc84fef 100644 --- a/rush-plugins/rush-serve-plugin/package.json +++ b/rush-plugins/rush-serve-plugin/package.json @@ -32,12 +32,16 @@ }, "devDependencies": { "@rushstack/heft": "workspace:*", + "@rushstack/heft-webpack5-plugin": "workspace:*", + "@rushstack/rush-serve-dashboard": "workspace:*", + "html-webpack-plugin": "~5.5.0", "eslint": "~9.37.0", "local-node-rig": "workspace:*", "@types/compression": "~1.7.2", "@types/cors": "~2.8.12", "@types/express": "4.17.21", - "@types/ws": "8.18.1" + "@types/ws": "8.18.1", + "webpack": "~5.105.2" }, "exports": { ".": { diff --git a/rush-plugins/rush-serve-plugin/webpack.config.js b/rush-plugins/rush-serve-plugin/webpack.config.js new file mode 100644 index 00000000000..fb83bfd350d --- /dev/null +++ b/rush-plugins/rush-serve-plugin/webpack.config.js @@ -0,0 +1,50 @@ +'use strict'; + +const path = require('node:path'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); + +const dashboardPackageFolder = path.dirname(require.resolve('@rushstack/rush-serve-dashboard/package.json')); + +function createWebpackConfig({ production }) { + return ['lib-commonjs', 'lib-esm'].map((outputFolderName) => ({ + mode: production ? 'production' : 'development', + target: 'web', + devtool: 'source-map', + experiments: { + css: true + }, + entry: path.join(dashboardPackageFolder, 'lib-esm', 'dashboard.js'), + module: { + rules: [ + { + test: /\.module\.css$/i, + type: 'css/module', + parser: { + dashedIdents: false, + namedExports: false + } + }, + { + test: /\.css$/i, + exclude: /\.module\.css$/i, + type: 'css' + } + ] + }, + output: { + path: path.join(__dirname, outputFolderName, 'dashboard'), + filename: 'dashboard.js', + cssFilename: 'dashboard.css' + }, + plugins: [ + new HtmlWebpackPlugin({ + filename: 'dashboard.html', + inject: 'body', + scriptLoading: 'module', + template: path.join(dashboardPackageFolder, 'assets', 'dashboard.html') + }) + ] + })); +} + +module.exports = createWebpackConfig; 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",