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/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/rush-plugins/rush-serve-plugin/README.md b/rush-plugins/rush-serve-plugin/README.md index 11180aecaef..0087a181d3f 100644 --- a/rush-plugins/rush-serve-plugin/README.md +++ b/rush-plugins/rush-serve-plugin/README.md @@ -22,6 +22,27 @@ 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 a ready-made dashboard whose source lives under `src/dashboard/` and whose browser-ready assets are emitted to `lib-esm/dashboard/`. Because the HTML references `dashboard.js` plus scoped styles under `styles/`, serve the ESM build folder rather than a single file: + +```json +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush-serve-plugin-options.schema.json", + "phasedCommands": ["start"], + "buildStatusWebSocketPath": "/ws", + "globalRouting": [ + { + "workspaceRelativeFolder": "rush-plugins/rush-serve-plugin/lib-esm/dashboard", + "servePath": "/dashboard", + "immutable": false + } + ] +} +``` + +The Rush server resolves extensionless module requests to emitted `.js` files. + +Then open `https://localhost:/dashboard/dashboard.html`. + 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..6c5352da3cb 100644 --- a/rush-plugins/rush-serve-plugin/config/heft.json +++ b/rush-plugins/rush-serve-plugin/config/heft.json @@ -20,6 +20,22 @@ ] } } + }, + "copy-dashboard-assets": { + "taskPlugin": { + "pluginPackage": "@rushstack/heft", + "pluginName": "copy-files-plugin", + "options": { + "copyOperations": [ + { + "sourcePath": "src/dashboard", + "destinationFolders": ["lib-commonjs/dashboard", "lib-esm/dashboard"], + "fileExtensions": [".html", ".css"], + "hardlink": true + } + ] + } + } } } } diff --git a/rush-plugins/rush-serve-plugin/eslint.config.js b/rush-plugins/rush-serve-plugin/eslint.config.js index 87132f43292..c1b8e03f28c 100644 --- a/rush-plugins/rush-serve-plugin/eslint.config.js +++ b/rush-plugins/rush-serve-plugin/eslint.config.js @@ -9,6 +9,25 @@ module.exports = [ ...nodeProfile, ...friendlyLocalsMixin, ...tsdocMixin, + { + files: ['src/dashboard/**/*.ts'], + languageOptions: { + sourceType: 'module', + globals: { + URL: 'readonly', + URLSearchParams: 'readonly', + WebSocket: 'readonly', + document: 'readonly', + history: 'readonly', + location: 'readonly', + localStorage: 'readonly', + navigator: 'readonly', + requestAnimationFrame: 'readonly', + self: 'readonly', + window: 'readonly' + } + } + }, { files: ['**/*.ts', '**/*.tsx'], languageOptions: { diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/dashboard.html b/rush-plugins/rush-serve-plugin/src/dashboard/dashboard.html new file mode 100644 index 00000000000..f7776bf5238 --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/dashboard/dashboard.html @@ -0,0 +1,253 @@ + + + + + + + + rush-serve-plugin Demo + + + + + + + + + + + +
+
+ + Disconnected +
+
Rush Serve Dashboard
+
+ + + + + + + + +
+
+
+ + + + +
+
+
+ + +
+
+ +
+
+ Dependency Graph + +
+
+ + + + + + + + +
+
+ +
+
+
+
+ + + +
+
+
+
+ +
+ +
+
+
Terminal Output
+
+ + + + + +
+
+
+
+
+ + + diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/dashboard.ts b/rush-plugins/rush-serve-plugin/src/dashboard/dashboard.ts new file mode 100644 index 00000000000..a9db5d82e24 --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/dashboard/dashboard.ts @@ -0,0 +1,517 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/* eslint-disable @typescript-eslint/typedef */ +/* eslint-disable prefer-const */ +/* eslint-disable @rushstack/no-new-null */ +/* eslint-disable @typescript-eslint/no-use-before-define */ + +import { + applyExecutionStates as applyExecutionStatesMutation, + patchOperationsFromPayload, + setOperationsFromPayload, + setQueuedStates, + toLastExecutionResultsMap +} from './modules/dashboardMutations'; +import { + computeWsUrl as computeWebSocketUrl, + overallStatusText, + setConnected as setTopBarConnected, + showConnectingStatus, + updateDerivedUrlDisplay as updateTopBarDerivedUrlDisplay, + updateManagerState as updateTopBarManagerState, + updateStatusPill as updateTopBarStatusPill, + type ITopBarRefs +} from './modules/topBar'; +import { createTerminalPaneController, type ITerminalPaneController } from './modules/terminalPane'; +import { createDashboardWebSocketController } from './modules/dashboardWebSocket'; +import { computeFilterSetsCore } from './modules/graphFiltering'; +import { createGraphViewController, graphState } from './modules/graphView'; +import { createGraphSelectionController } from './modules/graphSelection'; +import { createSelectionBarController } from './modules/selectionBar'; +import { createTableViewController } from './modules/tableView'; +import { createPhaseLegendController } from './modules/phaseLegend'; +import { wireLeftBarActions } from './modules/leftBar'; +import { wireMainBarActions } from './modules/mainBar'; +import { wireViewBar } from './modules/viewBar'; +import { loadDashboardUrlState, type DashboardFilter, type DashboardView } from './modules/urlState'; +import { + buildRunPolicyText, + buildTooltip, + computeDisplayStatus as computeDisplayStatusCore, + enabledGlyph, + getStatusColors, + statusEmoji +} from './modules/statusHelpers'; + +interface IOperationLogFileURLs { + text?: string; + error?: string; + jsonl?: string; +} + +interface IOperationInfo { + name: string; + dependencies?: string[]; + phaseName?: string; + packageName?: string; + noop?: boolean; + enabled?: string; + status?: string; + runInThisIteration?: boolean; + isActive?: boolean; + logFileURLs?: IOperationLogFileURLs; +} + +interface IOperationExecutionState { + name: string; + status?: string; + runInThisIteration?: boolean; + isActive?: boolean; + logFileURLs?: IOperationLogFileURLs; +} + +interface IDashboardGraphState { + status?: string; + debugMode?: boolean; + verbose?: boolean; + pauseNextIteration?: boolean; + parallelism?: number | string; + hasScheduledIteration?: boolean; +} + +interface IDashboardSessionInfo { + actionName: string; + repositoryIdentifier: string; +} + +interface IDashboardMessage { + event: string; + operations?: IOperationInfo[]; + currentExecutionStates?: IOperationExecutionState[]; + executionStates?: IOperationExecutionState[]; + queuedStates?: IOperationExecutionState[]; + graphState?: IDashboardGraphState; + resultByOperation?: IOperationExecutionState[]; + status?: string; + sessionInfo?: IDashboardSessionInfo; + kind?: string; + text?: string; +} + +interface IGraphViewControllerLike { + markGraphDirty(): void; + ensureGraph(): void; + updateGraph(): void; +} + +const statusPill = document.getElementById('status-pill') as HTMLElement; +const statusEmojiEl = document.getElementById('status-emoji') as HTMLElement; +const connectBtn = document.getElementById('connect-btn') as HTMLButtonElement | null; +const appTitleEl = document.getElementById('app-title') as HTMLElement | null; +const tableEl = document.getElementById('operations-table') as HTMLTableElement; +const tableHead = tableEl.querySelector('thead'); +const tableBody = tableEl.querySelector('tbody'); +const tableStats = document.getElementById('table-stats') as HTMLElement | null; +const managerStateEl = document.getElementById('graph-state') as HTMLElement | null; +const edgesSvg = document.getElementById('edges') as unknown as SVGSVGElement; +const graphEl = document.getElementById('graph') as HTMLElement; +const legendEl = document.getElementById('graph-legend') as HTMLElement | null; +const phasePaneEl = document.getElementById('phase-pane') as HTMLElement | null; +const phaseGroupsEl = phasePaneEl ? phasePaneEl.querySelector('.phase-groups') : null; +const playPauseBtn = document.getElementById('play-pause-btn') as HTMLButtonElement | null; +const parallelismInput = document.getElementById('parallelism-input') as HTMLInputElement | null; +const debugBtn = document.getElementById('debug-btn') as HTMLButtonElement | null; +const verboseBtn = document.getElementById('verbose-btn') as HTMLButtonElement | null; +const terminalEl = document.getElementById('terminal') as HTMLElement | null; +const terminalBody = document.getElementById('terminal-body') as HTMLElement | null; +const termClearBtn = document.getElementById('term-clear-btn') as HTMLButtonElement | null; +const termAutoScroll = document.getElementById('term-autoscroll') as HTMLInputElement | null; +const termAutoscrollBtn = document.getElementById('term-autoscroll-btn') as HTMLButtonElement | null; +const toggleTerminalBtn = document.getElementById('toggle-terminal-btn') as HTMLButtonElement | null; +const resizerEl = document.getElementById('resizer') as HTMLElement | null; + +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' +]; + +let operations: Map = new Map(); +let executionStates: Map = new Map(); +let queuedStates: Map = new Map(); +let lastExecutionResults: Map = new Map(); +let selection: Set = new Set(); +let graphSettings: IDashboardGraphState | null = null; +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 = computeFilterSetsCore({ + operations, + executionStates, + currentFilter, + searchQuery, + computeDisplayStatus + }); + filteredOutNames = result.filteredOutNames; + searchFilteredOutNames = result.searchFilteredOutNames; + return result.visibleOperations; +} + +const tableViewController = 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 = createPhaseLegendController({ + phaseGroupsEl: phaseGroupsEl || undefined, + legendEl: legendEl || undefined, + getOperations: () => operations, + getGraphVisibleNames: () => + graphState.nodePositions.size ? new Set(graphState.nodePositions.keys()) : undefined, + computeDisplayStatus, + statusEmoji, + overallStatusText, + getStatusColors +}); + +let graphSelectionController: ReturnType; + +function selectionChanged(): void { + updateSelectionUI(); + render(); +} + +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 = createGraphSelectionController({ + graphEl, + getCurrentView: () => currentView, + getSelection: () => selection, + setSelection: (nextSelection: Set) => { + selection = nextSelection; + }, + getOperations: () => operations, + getGraphNodePositions: () => graphState.nodePositions, + graphNodeWidth: 28, + graphNodeHeight: 28, + onSelectionChanged: selectionChanged, + onLiveSelectionChanged: updateGraph +}); + +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 updateStatusPill(): void { + updateTopBarStatusPill(topBarRefs, socketController.getSocket(), graphSettings, statusEmoji); +} + +function updateManagerState(): void { + if (!graphSettings) return; + updateTopBarManagerState(topBarRefs, graphSettings); +} + +function log(message: string): void { + const time: string = new Date().toLocaleTimeString(); + // eslint-disable-next-line no-console + console.log('[' + time + '] ' + message); +} + +const socketController = 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 +}); + +const selectionBarController = 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 || null; + lastExecutionResults = toLastExecutionResultsMap(msg.resultByOperation || []); + if (appTitleEl && msg.sessionInfo) { + const title = `${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 || null; + 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 = 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/rush-plugins/rush-serve-plugin/src/dashboard/modules/ansiSgrParser.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/ansiSgrParser.ts new file mode 100644 index 00000000000..bfed37ebff4 --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/ansiSgrParser.ts @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/* eslint-disable @rushstack/no-new-null */ +/* eslint-disable no-control-regex */ + +export interface IAnsiSegment { + text: string; + style: string; +} + +interface IAnsiState { + bold: boolean; + underline: boolean; + inverse: boolean; + fg: string | null; + bg: string | null; +} + +export class AnsiSgrParser { + private readonly _state: IAnsiState = { + bold: false, + underline: false, + inverse: false, + fg: null, + bg: null + }; + + public process(input: string): IAnsiSegment[] { + const csiRegex: RegExp = /\x1b\[[0-9;]*m/g; + let match: RegExpExecArray | null; + let lastIndex: 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 ((match = csiRegex.exec(input)) !== null) { + const idx: number = match.index; + if (idx > lastIndex) { + pushSegmentIfText(input.slice(lastIndex, idx)); + } + + const seq: string = match[0]; + try { + this._applySgr(this._parseSgrParams(seq)); + } catch { + // Ignore malformed control sequences. + } + + lastIndex = csiRegex.lastIndex; + } + + 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 = null; + this._state.bg = null; + } 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 = null; + } else if (p >= 40 && p <= 47) { + this._state.bg = this._sgrColorToCss(p - 40, false); + } else if (p === 49) { + this._state.bg = null; + } 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 | null { + 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] || null; + } + + 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/rush-plugins/rush-serve-plugin/src/dashboard/modules/dashboardMutations.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/dashboardMutations.ts new file mode 100644 index 00000000000..a4f2f36b8b4 --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/dashboardMutations.ts @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export function applyExecutionStates( + operations: Map, + executionStates: Map, + stateArray: any[] +): void { + if (!stateArray) return; + + stateArray.forEach((stateEntry) => { + executionStates.set(stateEntry.name, stateEntry); + + const op: any = 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: any[]): void { + queuedStates.clear(); + if (!stateArray) return; + + stateArray.forEach((stateEntry) => { + queuedStates.set(stateEntry.name, stateEntry); + }); +} + +export function setOperationsFromPayload(operations: Map, operationArray: any[]): void { + operations.clear(); + operationArray.forEach((op) => operations.set(op.name, op)); +} + +export function patchOperationsFromPayload(operations: Map, operationArray: any[]): void { + operationArray.forEach((op) => operations.set(op.name, op)); +} + +export function toLastExecutionResultsMap(lastExecutionResultsArray: any[] | undefined): Map { + const result: Map = new Map(); + if (!lastExecutionResultsArray) return result; + + lastExecutionResultsArray.forEach((entry) => { + result.set(entry.name, entry); + }); + + return result; +} diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/modules/dashboardWebSocket.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/dashboardWebSocket.ts new file mode 100644 index 00000000000..b4202fcf001 --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/dashboardWebSocket.ts @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/* eslint-disable @rushstack/no-new-null */ +/* eslint-disable prefer-const */ + +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 | null; +} + +export function createDashboardWebSocketController( + options: IDashboardWebSocketControllerOptions +): IDashboardWebSocketController { + let ws: WebSocket | null = null; + let reconnectTimer: ReturnType | null = null; + let manualDisconnect: boolean = false; + + const isConnected = (): boolean => !!ws && ws.readyState === WebSocket.OPEN; + const getSocket = (): WebSocket | null => ws; + + let connect: () => void; + + const scheduleReconnect = (): void => { + if (reconnectTimer) return; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + if (!manualDisconnect) connect(); + }, 4000); + }; + + connect = (): void => { + if (ws && ws.readyState === WebSocket.OPEN) return; + + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + 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 = null; + 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 = null; + } + if (ws) { + try { + ws.close(); + } catch { + // ignore close failures + } + ws = null; + } + }; + + 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/rush-plugins/rush-serve-plugin/src/dashboard/modules/graphFiltering.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/graphFiltering.ts new file mode 100644 index 00000000000..dc7c899cfcb --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/graphFiltering.ts @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export interface IComputeFilterSetsOptions { + operations: Map; + executionStates: Map; + currentFilter: 'all' | 'failed-warn'; + searchQuery: string; + computeDisplayStatus: (op: any) => string; +} + +export interface IComputeFilterSetsResult { + visibleOperations: any[]; + 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: any[] = []; + const query: string = searchQuery.trim().toLowerCase(); + + for (const op of operations.values()) { + const state: any = executionStates.get(op.name) || {}; + + // 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: any[]): any[] { + 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: any = 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: any = 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/rush-plugins/rush-serve-plugin/src/dashboard/modules/graphSelection.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/graphSelection.ts new file mode 100644 index 00000000000..e721da2f092 --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/dashboard/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. + +/* eslint-disable @typescript-eslint/typedef */ + +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 = pos.x; + const ny1 = pos.y; + const nx2 = pos.x + options.graphNodeWidth; + const ny2 = pos.y + options.graphNodeHeight; + if (nx2 < x1 || nx1 > x2 || ny2 < y1 || ny1 > y2) continue; + newlySelected.add(name); + } + + let nextSelection: Set; + if (dragModifierMode === 'add') { + nextSelection = new Set(preDragSelection || []); + newlySelected.forEach((name) => nextSelection.add(name)); + } else if (dragModifierMode === 'subtract') { + nextSelection = new Set(preDragSelection || []); + newlySelected.forEach((name) => nextSelection.delete(name)); + } else { + nextSelection = newlySelected; + } + + options.setSelection(nextSelection); + options.onLiveSelectionChanged(); + }; + + const _beginDragSelection = (e: MouseEvent): void => { + if (options.getCurrentView() !== 'graph' || e.button !== 0) return; + const target: Element | null = e.target as Element | null; + if (target && target.closest && target.closest('.op-node')) return; + + dragSelecting = true; + dragStart = _graphPointFromEvent(e); + dragLast = dragStart; + preDragSelection = new Set(options.getSelection()); + graphMarqueeEl = document.createElement('div'); + graphMarqueeEl.className = 'graph-marquee'; + options.graphEl.appendChild(graphMarqueeEl); + _updateMarquee(); + e.preventDefault(); + }; + + const _wireGraphMarqueeSelection = (): void => { + options.graphEl.addEventListener('mousedown', (e: MouseEvent) => { + _updateDragModifierMode(e); + _beginDragSelection(e); + }); + + options.graphEl.addEventListener('mousemove', (e: MouseEvent) => { + if (!dragSelecting) return; + dragLast = _graphPointFromEvent(e); + _updateDragModifierMode(e); + _updateMarquee(); + e.preventDefault(); + }); + + window.addEventListener('mouseup', () => { + if (!dragSelecting) return; + dragSelecting = false; + if (graphMarqueeEl) { + graphMarqueeEl.remove(); + graphMarqueeEl = null; + } + dragStart = null; + dragLast = null; + preDragSelection = null; + }); + + window.addEventListener('keydown', (e: KeyboardEvent) => { + if (dragSelecting) { + _updateDragModifierMode(e); + _updateMarquee(); + } + }); + + window.addEventListener('keyup', (e: KeyboardEvent) => { + if (dragSelecting) { + _updateDragModifierMode(e); + _updateMarquee(); + } + }); + }; + + const _singleSelect = (name: string): void => { + options.setSelection(new Set([name])); + _selectionChanged(); + }; + + const _toggleSelect = (name: string): void => { + const nextSelection: Set = new Set(options.getSelection()); + if (nextSelection.has(name)) nextSelection.delete(name); + else nextSelection.add(name); + options.setSelection(nextSelection); + _selectionChanged(); + }; + + const _expandSelectionDependencies = (): void => { + const currentSelection: Set = options.getSelection(); + if (!currentSelection.size) return; + + const queue: string[] = [...currentSelection]; + const seen: Set = new Set(currentSelection); + const operations: Map = options.getOperations(); + + while (queue.length) { + const name: string | undefined = queue.shift(); + if (!name) continue; + const op: IOperationInfo | undefined = operations.get(name); + if (!op) continue; + for (const dep of op.dependencies || []) { + if (!seen.has(dep) && operations.has(dep)) { + seen.add(dep); + queue.push(dep); + } + } + } + + if (seen.size !== currentSelection.size) { + options.setSelection(seen); + _selectionChanged(); + } + }; + + const _expandSelectionConsumers = (): void => { + const currentSelection: Set = options.getSelection(); + if (!currentSelection.size) return; + + const operations: Map = options.getOperations(); + const dependents: Map> = new Map>(); + for (const op of operations.values()) { + for (const dep of op.dependencies || []) { + if (!operations.has(dep)) continue; + let setForDep: Set | undefined = dependents.get(dep); + if (!setForDep) { + setForDep = new Set(); + dependents.set(dep, setForDep); + } + setForDep.add(op.name); + } + } + + const queue: string[] = [...currentSelection]; + const seen: Set = new Set(currentSelection); + while (queue.length) { + const name: string | undefined = queue.shift(); + if (!name) continue; + const consumers: Set | undefined = dependents.get(name); + if (!consumers) continue; + for (const consumer of consumers) { + if (!seen.has(consumer)) { + seen.add(consumer); + queue.push(consumer); + } + } + } + + if (seen.size !== currentSelection.size) { + options.setSelection(seen); + _selectionChanged(); + } + }; + + return { + singleSelect: _singleSelect, + toggleSelect: _toggleSelect, + expandSelectionDependencies: _expandSelectionDependencies, + expandSelectionConsumers: _expandSelectionConsumers, + wireGraphMarqueeSelection: _wireGraphMarqueeSelection + }; +} diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/modules/graphView.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/graphView.ts new file mode 100644 index 00000000000..33139aa3fb7 --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/graphView.ts @@ -0,0 +1,553 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/* eslint-disable @typescript-eslint/typedef */ + +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 = 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 = 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 = queue.shift(); + if (!cur) continue; + const curLevel = 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 = options.getFilteredOutNames(); + const searchFilteredOutNames = 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 = 0.55): string { + if (!hex || !/^#?[0-9a-fA-F]{6}$/.test(hex)) return hex || '#4b5563'; + if (hex[0] === '#') hex = hex.slice(1); + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + const br = 30; + const bg = 41; + const bb = 59; + const nr = Math.round(r * (1 - amount) + br * amount); + const ng = Math.round(g * (1 - amount) + bg * amount); + const nb = 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]) => + `` + ) + .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 = 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 = memoCpl.get(name); + if (cached !== undefined) return cached; + const op = 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 = 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 = groups[levelValue] || []; + nodes.sort((a: string, b: string) => { + const cplA = criticalPathLen(a); + const cplB = criticalPathLen(b); + if (cplA !== cplB) return cplB - cplA; + const consA = (dependentsMap.get(a) || new Set()).size; + const consB = (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 = options.getOperations().get(name); + if (!op) return; + const x = GRAPH_BASE_X + index * (GRAPH_COL_WIDTH + GRAPH_NODE_GAP); + const y = GRAPH_BASE_Y + levelIndexFromTop * GRAPH_LEVEL_GAP; + const div = document.createElement('div'); + div.className = 'op-node'; + div.dataset.name = name; + div.style.transform = `translate(${x}px, ${y}px)`; + const emojiSpan = document.createElement('span'); + emojiSpan.className = 'emoji'; + emojiSpan.textContent = options.getStatusEmoji(options.getComputeDisplayStatus(op)); + div.appendChild(emojiSpan); + const enabledSup = document.createElement('span'); + enabledSup.className = 'enabled-indicator'; + enabledSup.textContent = ''; + div.appendChild(enabledSup); + div.addEventListener('click', (e) => { + e.stopPropagation(); + if (e.metaKey || e.ctrlKey) options.toggleSelect(name); + else options.singleSelect(name); + }); + options.graphEl.appendChild(div); + graphState.nodePositions.set(name, { x, y }); + graphState.nodeElements.set(name, div); + }); + }); + + const byName: Map = new Map( + filteredOpsArr.map((op) => [op.name, op]) + ); + const memoReach: Map> = new Map>(); + function getReachable(name: string): Set { + const cached = memoReach.get(name); + if (cached) return cached; + const op = byName.get(name); + const visited: Set = new Set(); + if (op) { + const stack: string[] = [...(op.dependencies || [])]; + while (stack.length) { + const dependencyName = stack.pop(); + if (!dependencyName) continue; + if (visited.has(dependencyName)) continue; + visited.add(dependencyName); + const dependencyOp = 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 = op.dependencies || []; + for (const depName of deps) { + if (!byName.has(depName)) continue; + let redundant = 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 = graphState.nodePositions.get(op.name); + const toPos = graphState.nodePositions.get(depName); + if (!fromPos || !toPos) continue; + const path = 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 = + Math.max(...Array.from(graphState.nodePositions.values()).map((p) => p.x)) + GRAPH_NODE_WIDTH + 40; + const maxY = + 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 = options.getOperations().get(name); + if (!op) continue; + const displayStatus = options.getComputeDisplayStatus(op); + const prevStatus = graphState.nodeStatus.get(name); + const state = options.getExecutionStates().get(name); + const runInThisIteration = state ? state.runInThisIteration : op.runInThisIteration; + const notRunning = runInThisIteration === false || op.noop; + const queuedState = options.getQueuedStates().get(name); + const isQueuedNext = !!(queuedState && queuedState.runInThisIteration === true); + const isFilteredOut = options.getFilteredOutNames().has(name); + const isSearchFiltered = options.getSearchFilteredOutNames().has(name); + const emojiSpan = div.querySelector('.emoji'); + if (emojiSpan && (prevStatus !== displayStatus || !emojiSpan.textContent)) { + emojiSpan.textContent = options.getStatusEmoji(displayStatus); + } + const enabledSpan = div.querySelector('.enabled-indicator') as HTMLSpanElement | null; + if (enabledSpan) { + let indicator = ''; + 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 = statusColors[displayStatus] || '#4b5563'; + if (isSearchFiltered) baseColor = dimColor(baseColor, 0.72); + else if (isFilteredOut) baseColor = dimColor(baseColor, 0.6); + else if (notRunning) baseColor = dimColor(baseColor, 0.35); + div.style.borderColor = baseColor; + if (options.getSelection().has(name)) div.classList.add('selected'); + else div.classList.remove('selected'); + let activeSpan = div.querySelector('.active-indicator') as HTMLSpanElement | null; + if (op.isActive) { + if (!activeSpan) { + activeSpan = document.createElement('span'); + activeSpan.className = 'active-indicator'; + activeSpan.textContent = '⚑'; + div.appendChild(activeSpan); + } + activeSpan.title = 'Active (in-memory state)'; + } else if (activeSpan) { + activeSpan.remove(); + } + let pendingSpan = div.querySelector('.pending-indicator') as HTMLSpanElement | null; + if (isQueuedNext) { + if (!pendingSpan) { + pendingSpan = document.createElement('span'); + pendingSpan.className = 'pending-indicator'; + pendingSpan.textContent = 'πŸ•’'; + div.appendChild(pendingSpan); + } + pendingSpan.title = 'Pending changes (iteration queued)'; + } else if (pendingSpan) { + pendingSpan.remove(); + } + div.classList.remove('not-running', 'filtered-out', 'filtered-out-search', 'dashed', 'dotted'); + if (isSearchFiltered) { + div.classList.add('filtered-out-search'); + } else if (isFilteredOut) { + div.classList.add('filtered-out'); + } else if (notRunning) { + div.classList.add('not-running'); + } + div.title = `${op.name}\nLast Result: ${(options.getLastExecutionResults().get(name) || {}).status || displayStatus}\n${options.getOverallStatusText(displayStatus)}${op.isActive ? '\nHas in-memory state' : ''}`; + graphState.nodeStatus.set(name, displayStatus); + } + + for (const rec of graphState.edgeElements) { + const fromPos = graphState.nodePositions.get(rec.from); + const toPos = graphState.nodePositions.get(rec.to); + if (!fromPos || !toPos) continue; + const startX = fromPos.x + GRAPH_NODE_WIDTH / 2; + const startY = fromPos.y + GRAPH_NODE_HEIGHT; + const endX = toPos.x + GRAPH_NODE_WIDTH / 2; + const endY = toPos.y; + const rowsApart = Math.max(1, Math.round((toPos.y - fromPos.y) / GRAPH_LEVEL_GAP)); + const colStep = GRAPH_COL_WIDTH + GRAPH_NODE_GAP; + function quadratic(sx: number, sy: number, ex: number, ey: number): string { + const mx = (sx + ex) / 2; + const my = (sy + ey) / 2; + const baseOffset = (ey - sy) / 4; + return `Q ${sx} ${sy + baseOffset} ${mx} ${my} ${ex} ${my + baseOffset} ${ex} ${ey}`; + } + let d = ''; + 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 = Math.sign(endX - startX) || 1; + const halfColShift = colStep / 2; + const candidateX = startX + (endX - startX) * 0.5; + const deltaCols = Math.max(1, Math.round(Math.abs(endX - startX) / colStep)); + let intermediateX = candidateX; + let tooClose = false; + for (let k = 0; k <= deltaCols; k++) { + const center = 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 = fromPos.y + GRAPH_LEVEL_GAP; + const bottomOfRowAboveDest = toPos.y - GRAPH_LEVEL_GAP + GRAPH_NODE_HEIGHT; + const midY1 = firstTargetY; + const midY2 = 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 = graphState.nodeStatus.get(rec.to) || 'Ready'; + rec.path.setAttribute('stroke', statusColors[depStatus] || '#4b5563'); + rec.path.setAttribute('class', 'edge'); + rec.path.setAttribute('marker-end', `url(#arrowhead-${depStatus})`); + const fromOp = options.getOperations().get(rec.from); + if (options.getSelection().has(rec.to) && options.getSelection().has(rec.from)) + rec.path.classList.add('highlight'); + else rec.path.classList.remove('highlight'); + rec.path.classList.remove('dashed', 'dotted', 'filtered-out', 'not-running'); + rec.path.classList.remove('filtered-out-search'); + const fromState = fromOp ? options.getExecutionStates().get(rec.from) : undefined; + const fromRunInThisIteration = fromState ? fromState.runInThisIteration : fromOp?.runInThisIteration; + const fromNotRunning = fromOp && (fromRunInThisIteration === false || fromOp.noop); + const edgeStatusFiltered = + options.getFilteredOutNames().has(rec.from) || options.getFilteredOutNames().has(rec.to); + const edgeSearchFiltered = + options.getSearchFilteredOutNames().has(rec.from) || options.getSearchFilteredOutNames().has(rec.to); + if (edgeSearchFiltered || edgeStatusFiltered || fromNotRunning) { + let strokeColor = 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 = depStatus === 'Executing' || depStatus === 'Failure'; + if (!semImportant && !rec.path.classList.contains('highlight')) { + rec.path.classList.add('dim'); + } else { + rec.path.classList.remove('dim'); + } + } + } + + options.renderPhaseLegend(); + } + + function markGraphDirty(): void { + graphNeedsFullRender = true; + } + + function ensureGraph(): void { + if (graphNeedsFullRender) { + buildGraph(); + graphNeedsFullRender = false; + } else { + updateGraph(); + } + } + + return { + markGraphDirty, + ensureGraph, + updateGraph + }; +} diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/modules/leftBar.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/leftBar.ts new file mode 100644 index 00000000000..f460ed7360e --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/leftBar.ts @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export interface ILeftBarActionWiringOptions { + sendCommand: (cmd: any) => 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/rush-plugins/rush-serve-plugin/src/dashboard/modules/mainBar.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/mainBar.ts new file mode 100644 index 00000000000..667627e67cd --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/mainBar.ts @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/* eslint-disable @rushstack/no-new-null */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/typedef */ + +export interface IMainBarActionWiringOptions { + connect: () => void; + disconnect: () => void; + isConnected: () => boolean; + sendCommand: (cmd: any) => void; + getGraphSettings: () => any; + debugBtn: HTMLElement | null; + verboseBtn: HTMLElement | null; + parallelismInput: HTMLInputElement | null; + playPauseBtn: HTMLElement | null; + 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 | null = document.getElementById('connect-btn'); + if (connectBtn) { + connectBtn.addEventListener('click', () => { + if (isConnected()) { + disconnect(); + } else { + connect(); + } + }); + } + + const executeBtn: HTMLElement | null = document.getElementById('execute-btn'); + if (executeBtn) { + executeBtn.addEventListener('click', () => sendCommand({ command: 'execute' })); + } + + const abortBtn: HTMLElement | null = document.getElementById('abort-execution-btn'); + 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', () => { + sendCommand({ command: 'set-parallelism', parallelism: Number(parallelismInput.value) || 1 }); + }); + } + + if (playPauseBtn) { + playPauseBtn.addEventListener('click', () => { + const graphSettings = 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/rush-plugins/rush-serve-plugin/src/dashboard/modules/phaseLegend.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/phaseLegend.ts new file mode 100644 index 00000000000..f50b5125bd4 --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/phaseLegend.ts @@ -0,0 +1,446 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/* eslint-disable @typescript-eslint/typedef */ + +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 = 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 = 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 = computePhaseSummaries(); + (options.phaseGroupsEl as HTMLElement).innerHTML = ''; + + if (!summaries.length) { + const empty = document.createElement('div'); + empty.className = 'phase-pane-empty'; + empty.textContent = 'No phases'; + options.phaseGroupsEl.appendChild(empty); + return; + } + + for (const summary of summaries) { + const group = document.createElement('div'); + group.className = 'phase-group'; + + const header = document.createElement('div'); + header.className = 'phase-header'; + + const emoji = document.createElement('span'); + emoji.className = 'phase-status-emoji'; + emoji.textContent = options.statusEmoji(summary.status); + + const nameSpan = document.createElement('span'); + nameSpan.className = 'phase-name'; + nameSpan.textContent = summary.phase.replace(/^_phase:/, ''); + + header.appendChild(emoji); + header.appendChild(nameSpan); + group.appendChild(header); + + if (summary.problemOps.length) { + const list = document.createElement('ul'); + list.className = 'phase-problems'; + + const sortedProblems = [...summary.problemOps].sort((a, b) => { + const ai = phaseStatusPriorityIndex.get(a.displayStatus) ?? 999; + const bi = phaseStatusPriorityIndex.get(b.displayStatus) ?? 999; + if (ai !== bi) return ai - bi; + const an = a.op.name.toLowerCase(); + const bn = b.op.name.toLowerCase(); + if (an < bn) return -1; + if (an > bn) return 1; + return 0; + }); + + for (const { op, displayStatus } of sortedProblems) { + const item = document.createElement('li'); + + const status = document.createElement('span'); + status.className = 'phase-problem-emoji'; + status.textContent = options.statusEmoji(displayStatus); + item.appendChild(status); + + const logUrl = + (op.logFileURLs && (op.logFileURLs.text || op.logFileURLs.error || op.logFileURLs.jsonl)) || + undefined; + if (logUrl) { + const link = document.createElement('a'); + link.href = logUrl; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + link.textContent = op.name; + item.appendChild(link); + } else { + const span = 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 = document.createElement('h4'); + header.textContent = 'Legend'; + + const toggleBtn = document.createElement('button'); + toggleBtn.type = 'button'; + toggleBtn.id = 'legend-collapse-btn'; + toggleBtn.setAttribute('aria-label', 'Collapse legend'); + toggleBtn.style.background = 'transparent'; + toggleBtn.style.border = 'none'; + toggleBtn.style.color = 'var(--text)'; + toggleBtn.style.cursor = 'pointer'; + toggleBtn.style.fontSize = '12px'; + toggleBtn.style.padding = '2px 4px'; + toggleBtn.style.marginLeft = 'auto'; + toggleBtn.style.display = 'flex'; + toggleBtn.style.alignItems = 'center'; + toggleBtn.style.lineHeight = '1'; + toggleBtn.textContent = 'βˆ’'; + + header.appendChild(toggleBtn); + legendEl.appendChild(header); + legendEl._initialized = true; + + const collapsed: boolean = window.localStorage.getItem('rushServeLegendCollapsed') === '1'; + if (collapsed) legendEl.classList.add('collapsed'); + toggleBtn.textContent = collapsed ? '+' : 'βˆ’'; + toggleBtn.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + + toggleBtn.addEventListener('click', () => { + const isCollapsed = legendEl.classList.toggle('collapsed'); + window.localStorage.setItem('rushServeLegendCollapsed', isCollapsed ? '1' : '0'); + toggleBtn.textContent = isCollapsed ? '+' : 'βˆ’'; + toggleBtn.setAttribute('aria-label', isCollapsed ? 'Expand legend' : 'Collapse legend'); + toggleBtn.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true'); + renderLegend(); + }); + } + + while (legendEl.children.length > 1) { + const lastChild = legendEl.lastChild; + if (!lastChild) break; + legendEl.removeChild(lastChild); + } + + if (legendEl.classList.contains('collapsed')) { + const stub = document.createElement('div'); + stub.style.fontSize = '0.5rem'; + stub.style.opacity = '0.7'; + stub.textContent = 'Collapsed'; + legendEl.appendChild(stub); + return; + } + + const statusColors = options.getStatusColors(); + + const columnsWrap = document.createElement('div'); + columnsWrap.className = 'legend-columns'; + + const colPrimary = document.createElement('div'); + colPrimary.className = 'legend-col'; + + const primaryHead = document.createElement('div'); + primaryHead.className = 'legend-heading'; + primaryHead.textContent = 'Statuses'; + colPrimary.appendChild(primaryHead); + + for (const status of legendOrder) { + const row = document.createElement('div'); + row.className = 'legend-row'; + + const sample = document.createElement('span'); + sample.className = 'legend-emoji'; + sample.textContent = options.statusEmoji(status); + sample.style.borderColor = statusColors[status] || '#4b5563'; + + const labelWrap = document.createElement('div'); + labelWrap.className = 'legend-label-wrap'; + const titleSpan = document.createElement('span'); + titleSpan.textContent = options.overallStatusText(status); + labelWrap.appendChild(titleSpan); + + row.appendChild(sample); + row.appendChild(labelWrap); + colPrimary.appendChild(row); + } + + const unknownRow = document.createElement('div'); + unknownRow.className = 'legend-row'; + + const unknownSample = document.createElement('span'); + unknownSample.className = 'legend-emoji status-Unknown'; + unknownSample.textContent = '❓'; + unknownSample.style.borderColor = '#4b5563'; + + const unknownLabelWrap = document.createElement('div'); + unknownLabelWrap.className = 'legend-label-wrap'; + + const unknownTitle = document.createElement('span'); + unknownTitle.textContent = 'UNKNOWN'; + const unknownDetail = document.createElement('small'); + unknownDetail.textContent = 'Never executed'; + + unknownLabelWrap.appendChild(unknownTitle); + unknownLabelWrap.appendChild(unknownDetail); + + unknownRow.appendChild(unknownSample); + unknownRow.appendChild(unknownLabelWrap); + colPrimary.appendChild(unknownRow); + + const colSecondary = document.createElement('div'); + colSecondary.className = 'legend-col'; + + const secondaryHead = document.createElement('div'); + secondaryHead.className = 'legend-heading'; + secondaryHead.textContent = 'State Modifiers'; + colSecondary.appendChild(secondaryHead); + + const addModifier = (sampleFactory: () => HTMLElement, label: string, detail?: string): void => { + const row = document.createElement('div'); + row.className = 'legend-row'; + const sample = sampleFactory(); + + const labelWrap = document.createElement('div'); + labelWrap.className = 'legend-label-wrap'; + const titleSpan = document.createElement('span'); + titleSpan.textContent = label; + labelWrap.appendChild(titleSpan); + + if (detail) { + const small = 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 = document.createElement('span'); + sample.className = 'legend-emoji'; + if (borderStyle) sample.style.borderStyle = borderStyle; + if (borderColor) sample.style.borderColor = borderColor; + if (boxShadow) sample.style.boxShadow = boxShadow; + sample.textContent = ' '; + return sample; + }; + + const makeDashed = (): HTMLElement => makeNodeBox('dashed'); + const makeDotted = (): HTMLElement => makeNodeBox('dotted'); + + const makeActive = (): HTMLElement => { + const wrap = document.createElement('div'); + wrap.className = 'legend-enabled-sample'; + + const base = document.createElement('span'); + base.style.opacity = '0.15'; + base.style.fontSize = '11px'; + base.textContent = '⬜'; + wrap.appendChild(base); + + const rocket = 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 = document.createElement('div'); + wrap.className = 'legend-enabled-sample'; + + const base = document.createElement('span'); + base.style.opacity = '0.15'; + base.style.fontSize = '11px'; + base.textContent = '⬜'; + wrap.appendChild(base); + + const clock = 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 = document.createElement('div'); + wrap.className = 'legend-enabled-sample'; + const sub = document.createElement('span'); + sub.className = 'sub'; + sub.textContent = emoji; + wrap.appendChild(sub); + return wrap; + }; + + addModifier(makePending, 'Pending changes', 'Iteration queued'); + addModifier(makeActive, 'Active', 'In-memory state'); + addModifier(makeDashed, 'Not in this iteration', 'Excluded this iteration'); + addModifier(makeDotted, 'Filtered out', 'Hidden by view/search'); + + const enabledHead = document.createElement('div'); + enabledHead.className = 'legend-subheading'; + enabledHead.textContent = 'Enabled States'; + colSecondary.appendChild(enabledHead); + + addModifier(() => makeEnabledSample('🟒'), 'Enabled', 'Runs normally'); + addModifier(() => makeEnabledSample('🟑'), 'Ignore dependency changes', 'Skips if no local changes'); + addModifier(() => makeEnabledSample('πŸ”΄'), 'Disabled', 'Never runs'); + addModifier(() => makeEnabledSample('βšͺ'), 'No-op', 'Operation does no work'); + + columnsWrap.appendChild(colPrimary); + columnsWrap.appendChild(colSecondary); + legendEl.appendChild(columnsWrap); + }; + + return { + renderPhasePane, + renderLegend, + renderAll: () => { + renderPhasePane(); + renderLegend(); + } + }; +} diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/modules/selectionBar.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/selectionBar.ts new file mode 100644 index 00000000000..3e6cd6f7cfb --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/dashboard/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/rush-plugins/rush-serve-plugin/src/dashboard/modules/statusHelpers.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/statusHelpers.ts new file mode 100644 index 00000000000..8e8ac19d26f --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/statusHelpers.ts @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +interface IOperationExecutionStateLike { + name: string; + status?: string; + runInThisIteration?: boolean; +} + +interface IOperationInfoLike { + name: string; + status?: string; + runInThisIteration?: boolean; + isActive?: boolean; + noop?: boolean; + enabled?: string; +} + +const statusEmojiMap: Record = { + Ready: '⏸️', + Waiting: 'πŸ•˜', + Queued: 'πŸ“', + Executing: 'βš™οΈ', + Success: 'βœ…', + SuccessWithWarning: '⚠️', + Skipped: 'πŸ’€', + FromCache: '🟩', + Failure: '❌', + Blocked: '🚫', + NoOp: 'πŸ’€', + Aborted: 'πŸ›‘', + Disconnected: '⏸️', + Unknown: '❓' +}; + +export function statusEmoji(status: string): string { + return statusEmojiMap[status] || 'β€’'; +} + +export function computeDisplayStatus( + op: IOperationInfoLike, + executionStates: Map, + lastExecutionResults: Map +): string { + const state: IOperationExecutionStateLike | undefined = executionStates.get(op.name); + let displayStatus: string = state?.status || op.status || ''; + const runInThisIteration: boolean | undefined = state ? state.runInThisIteration : op.runInThisIteration; + + if (runInThisIteration === false) { + const prev: IOperationExecutionStateLike | undefined = lastExecutionResults.get(op.name); + displayStatus = prev?.status || 'Skipped'; + } + + if (!displayStatus) { + const last: IOperationExecutionStateLike | undefined = lastExecutionResults.get(op.name); + if (!last || !last.status) displayStatus = 'Unknown'; + } + + 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/rush-plugins/rush-serve-plugin/src/dashboard/modules/tableView.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/tableView.ts new file mode 100644 index 00000000000..e7e440ba300 --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/tableView.ts @@ -0,0 +1,270 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +interface ITableAnchorCoordinate { + row: number; + phase: number; +} + +interface ITablePackageRecord { + packageName: string; + byPhase: Map; +} + +interface ITableOperation { + name: string; + packageName?: string; + phaseName?: string; + isActive?: boolean; + enabled?: string; +} + +export interface ITableViewControllerOptions { + tableHead: HTMLElement | undefined; + tableBody: HTMLElement | undefined; + tableStats: HTMLElement | undefined; + getOperations: () => Map; + getFilteredOperations: () => ITableOperation[]; + getSelection: () => Set; + setSelection: (nextSelection: Set) => void; + onSelectionMutated: () => void; + computeDisplayStatus: (op: ITableOperation) => string; + enabledGlyph: (op: ITableOperation) => string; + buildRunPolicyText: (op: ITableOperation) => string; + buildTooltip: (op: ITableOperation, lastResultStatus: string) => string; + statusEmoji: (status: string) => string; + overallStatusText: (status: string | undefined) => string; +} + +export interface ITableViewController { + renderTable: () => void; +} + +export function createTableViewController(options: ITableViewControllerOptions): ITableViewController { + let tableOpOrder: string[] = []; + let lastTableAnchorName: string | undefined; + let lastTableAnchorCoord: ITableAnchorCoordinate | undefined; + let lastTablePhases: string[] = []; + let lastTablePackages: ITablePackageRecord[] = []; + + const buildPivotData = (): { phases: string[]; packages: ITablePackageRecord[] } => { + const allPhases: Set = new Set(); + for (const op of options.getOperations().values()) { + const phaseName: string = op.phaseName || '(none)'; + allPhases.add(phaseName); + } + const phases: string[] = Array.from(allPhases).sort(); + + const filteredOps: ITableOperation[] = options.getFilteredOperations(); + const packageMap: Map = new Map(); + for (const op of filteredOps) { + const packageName: string = op.packageName || '(unknown package)'; + const phaseName: string = op.phaseName || '(none)'; + let rec: ITablePackageRecord | undefined = packageMap.get(packageName); + if (!rec) { + rec = { packageName, byPhase: new Map() }; + packageMap.set(packageName, rec); + } + rec.byPhase.set(phaseName, op); + } + + const packages: ITablePackageRecord[] = Array.from(packageMap.values()).sort((a, b) => + a.packageName.localeCompare(b.packageName) + ); + + return { phases, packages }; + }; + + const commitSelection = (nextSelection: Set): void => { + options.setSelection(nextSelection); + options.onSelectionMutated(); + }; + + const handleMultiSelectGroup = (e: MouseEvent, names: string[]): void => { + const isMeta: boolean = e.metaKey || e.ctrlKey; + const isShift: boolean = e.shiftKey; + let nextSelection: Set = new Set(options.getSelection()); + + if (isShift && lastTableAnchorName && tableOpOrder.includes(lastTableAnchorName)) { + if (!isMeta) nextSelection = new Set(nextSelection); + names.forEach((name) => nextSelection.add(name)); + } else if (isMeta) { + let anyNew: boolean = false; + names.forEach((name) => { + if (!nextSelection.delete(name)) { + nextSelection.add(name); + anyNew = true; + } + }); + if (anyNew && names.length) lastTableAnchorName = names[0]; + } else { + nextSelection = new Set(names); + if (names.length) lastTableAnchorName = names[0]; + } + + commitSelection(nextSelection); + }; + + const handlePivotCellClick = ( + e: MouseEvent, + opName: string, + rowIndex: number, + phaseIndex: number + ): void => { + if (!opName) return; + + const isMeta: boolean = e.metaKey || e.ctrlKey; + const isShift: boolean = e.shiftKey; + + if (isShift && lastTableAnchorCoord) { + const { row: anchorRow, phase: anchorPhase } = lastTableAnchorCoord; + const rowStart: number = Math.min(anchorRow, rowIndex); + const rowEnd: number = Math.max(anchorRow, rowIndex); + const phaseStart: number = Math.min(anchorPhase, phaseIndex); + const phaseEnd: number = Math.max(anchorPhase, phaseIndex); + const rectNames: Set = new Set(); + + for (let row: number = rowStart; row <= rowEnd; row++) { + const packageRecord: ITablePackageRecord | undefined = lastTablePackages[row]; + if (!packageRecord) continue; + + for (let phase: number = phaseStart; phase <= phaseEnd; phase++) { + const phaseName: string | undefined = lastTablePhases[phase]; + if (!phaseName) continue; + const cellOp: ITableOperation | undefined = packageRecord.byPhase.get(phaseName); + if (cellOp) rectNames.add(cellOp.name); + } + } + + if (isMeta) { + const nextSelection: Set = new Set(options.getSelection()); + rectNames.forEach((name) => nextSelection.add(name)); + commitSelection(nextSelection); + } else { + commitSelection(rectNames); + } + + return; + } + + const nextSelection: Set = new Set(options.getSelection()); + if (isMeta) { + if (nextSelection.has(opName)) nextSelection.delete(opName); + else nextSelection.add(opName); + } else { + nextSelection.clear(); + nextSelection.add(opName); + } + + lastTableAnchorName = opName; + lastTableAnchorCoord = { row: rowIndex, phase: phaseIndex }; + commitSelection(nextSelection); + }; + + const renderTable = (): void => { + const tableHead: HTMLElement | undefined = options.tableHead; + const tableBody: HTMLElement | undefined = options.tableBody; + const tableStats: HTMLElement | undefined = options.tableStats; + if (!tableHead || !tableBody || !tableStats) return; + + const { phases, packages } = buildPivotData(); + tableOpOrder = []; + lastTablePhases = phases; + lastTablePackages = packages; + + tableHead.innerHTML = ''; + const headerRow: HTMLTableRowElement = document.createElement('tr'); + const packageHeader: HTMLTableCellElement = document.createElement('th'); + packageHeader.textContent = 'Package'; + headerRow.appendChild(packageHeader); + + for (const phase of phases) { + const th: HTMLTableCellElement = document.createElement('th'); + const displayPhase: string = phase.replace(/^_phase:/, ''); + th.textContent = displayPhase; + if (displayPhase !== phase) th.title = phase; + th.className = 'phase-col-header'; + th.style.cursor = 'pointer'; + th.addEventListener('click', (e: Event) => { + const phaseNames: string[] = []; + for (const op of options.getOperations().values()) { + if ((op.phaseName || '(none)') === phase) phaseNames.push(op.name); + } + handleMultiSelectGroup(e as MouseEvent, phaseNames); + }); + headerRow.appendChild(th); + } + tableHead.appendChild(headerRow); + + tableBody.innerHTML = ''; + let opCount: number = 0; + const selection: Set = options.getSelection(); + + packages.forEach((pkg, rowIndex) => { + const tr: HTMLTableRowElement = document.createElement('tr'); + tr.className = 'pkg-row'; + + const namesInRow: string[] = Array.from(pkg.byPhase.values()).map((op) => op.name); + const allSelected: boolean = !!namesInRow.length && namesInRow.every((name) => selection.has(name)); + if (allSelected) tr.classList.add('selected'); + + const pkgTd: HTMLTableCellElement = document.createElement('td'); + pkgTd.className = 'pkg-cell'; + pkgTd.textContent = pkg.packageName; + pkgTd.style.fontWeight = '600'; + pkgTd.style.cursor = 'pointer'; + pkgTd.addEventListener('click', (e: Event) => { + handleMultiSelectGroup(e as MouseEvent, namesInRow); + e.stopPropagation(); + }); + tr.appendChild(pkgTd); + + phases.forEach((phase, phaseIndex) => { + const td: HTMLTableCellElement = document.createElement('td'); + td.className = 'pivot-cell'; + td.style.whiteSpace = 'nowrap'; + + const op: ITableOperation | undefined = pkg.byPhase.get(phase); + if (op) { + opCount++; + const displayStatus: string = options.computeDisplayStatus(op); + const glyph: string = options.enabledGlyph(op); + const active: string = op.isActive ? '⚑' : ''; + td.innerHTML = ` + ${options.statusEmoji(displayStatus)} + ${escapeHtml(options.overallStatusText(displayStatus))} + ${glyph} + ${active} + `; + td.title = options.buildTooltip(op, displayStatus); + if (selection.has(op.name)) td.classList.add('selected'); + td.style.cursor = 'pointer'; + tableOpOrder.push(op.name); + td.addEventListener('click', (e: Event) => { + handlePivotCellClick(e as MouseEvent, op.name, rowIndex, phaseIndex); + e.stopPropagation(); + }); + } else { + td.innerHTML = 'β€”'; + } + + tr.appendChild(td); + }); + + tableBody.appendChild(tr); + }); + + tableStats.textContent = opCount + ' operations'; + }; + + return { + renderTable + }; +} + +function escapeHtml(s: string): string { + return String(s).replace( + /[&<>"']/g, + (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] as string + ); +} diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/modules/terminalPane.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/terminalPane.ts new file mode 100644 index 00000000000..cd9e1942958 --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/terminalPane.ts @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/* eslint-disable @rushstack/no-new-null */ +/* eslint-disable @typescript-eslint/typedef */ + +import { AnsiSgrParser } from './ansiSgrParser'; + +interface ITerminalElementWithState extends HTMLElement { + _savedWidth?: number; +} + +export interface ITerminalPaneRefs { + terminalEl: HTMLElement | null; + terminalBody: HTMLElement | null; + termClearBtn: HTMLElement | null; + termAutoScrollCheckbox: HTMLInputElement | null; + termAutoscrollBtn: HTMLElement | null; + toggleTerminalBtn: HTMLElement | null; + resizerEl: HTMLElement | null; +} + +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 = ansiParser.process(raw); + + if (segments && segments.length) { + for (const seg of segments) { + const span: HTMLSpanElement = document.createElement('span'); + span.className = 'term-chunk ' + (kind === 'stderr' ? 'stderr' : 'stdout'); + if (seg.style) span.setAttribute('style', seg.style); + span.textContent = seg.text; + terminalBody.appendChild(span); + } + } + + if (!termAutoScrollCheckbox || termAutoScrollCheckbox.checked) { + terminalBody.scrollTop = terminalBody.scrollHeight; + } + + if (terminalEl && !terminalEl.classList.contains('hidden')) { + terminalEl.classList.add('term-flash'); + setTimeout(() => terminalEl.classList.remove('term-flash'), 350); + } +} + +function _wireLayoutInit(terminalEl: HTMLElement | null): void { + try { + if (terminalEl) { + terminalEl.style.top = ''; + } + } catch { + // no-op + } +} + +function _wireClearButton(terminalBody: HTMLElement | null, termClearBtn: HTMLElement | null): void { + if (!terminalBody || !termClearBtn) return; + + termClearBtn.addEventListener('click', () => { + terminalBody.innerHTML = ''; + }); +} + +function _wireResizer(terminalEl: HTMLElement | null, resizerEl: HTMLElement | null): void { + if (!terminalEl || !resizerEl) return; + + const terminalWithState: ITerminalElementWithState = terminalEl as ITerminalElementWithState; + let dragging: boolean = false; + let startX: number = 0; + let startWidth: number = 0; + const minW: number = 120; + const maxW: number = Math.max(240, window.innerWidth - 200); + + resizerEl.addEventListener('pointerdown', (e: PointerEvent) => { + dragging = true; + startX = e.clientX; + startWidth = terminalWithState.getBoundingClientRect().width; + resizerEl.setPointerCapture(e.pointerId); + document.body.style.userSelect = 'none'; + }); + + window.addEventListener('pointermove', (e: PointerEvent) => { + if (!dragging) return; + const dx: number = startX - e.clientX; + let newW: number = startWidth + dx; + newW = Math.max(minW, Math.min(maxW, newW)); + + terminalWithState._savedWidth = newW; + if (!terminalWithState.classList.contains('hidden')) { + terminalWithState.style.width = newW + 'px'; + terminalWithState.style.flex = '0 0 ' + newW + 'px'; + } + }); + + window.addEventListener('pointerup', (e: PointerEvent) => { + if (!dragging) return; + dragging = false; + try { + resizerEl.releasePointerCapture(e.pointerId); + } catch { + // ignore mismatched pointer state + } + document.body.style.userSelect = ''; + }); + + resizerEl.tabIndex = 0; + resizerEl.addEventListener('keydown', (e: KeyboardEvent) => { + const step: number = 16; + const rect: DOMRect = terminalWithState.getBoundingClientRect(); + let w: number = rect.width; + if (e.key === 'ArrowLeft') w = Math.max(minW, w - step); + else if (e.key === 'ArrowRight') w = Math.min(maxW, w + step); + + terminalWithState._savedWidth = w; + if (!terminalWithState.classList.contains('hidden')) { + terminalWithState.style.width = w + 'px'; + terminalWithState.style.flex = '0 0 ' + w + 'px'; + } + }); +} + +function _wireToggle(refs: ITerminalPaneRefs): void { + const { toggleTerminalBtn, terminalEl, resizerEl, termAutoscrollBtn, termAutoScrollCheckbox } = refs; + if (!toggleTerminalBtn || !terminalEl) return; + + const terminalWithState: ITerminalElementWithState = terminalEl as ITerminalElementWithState; + + toggleTerminalBtn.addEventListener('click', () => { + const currentlyHidden: boolean = terminalWithState.classList.contains('hidden'); + + if (currentlyHidden) { + terminalWithState.classList.remove('hidden'); + if (resizerEl) resizerEl.classList.remove('hidden'); + + if (terminalWithState._savedWidth) { + terminalWithState.style.width = terminalWithState._savedWidth + 'px'; + terminalWithState.style.flex = '0 0 ' + terminalWithState._savedWidth + 'px'; + } else { + terminalWithState.style.width = ''; + terminalWithState.style.flex = ''; + } + + if (resizerEl) resizerEl.tabIndex = 0; + toggleTerminalBtn.setAttribute('aria-pressed', 'true'); + toggleTerminalBtn.classList.add('active'); + } else { + try { + terminalWithState._savedWidth = terminalWithState.getBoundingClientRect().width; + } catch { + // no-op + } + + terminalWithState.classList.add('hidden'); + if (resizerEl) { + resizerEl.classList.add('hidden'); + resizerEl.tabIndex = -1; + } + + terminalWithState.style.width = ''; + terminalWithState.style.flex = ''; + toggleTerminalBtn.setAttribute('aria-pressed', 'false'); + toggleTerminalBtn.classList.remove('active'); + } + }); + + const isVisible: boolean = !terminalWithState.classList.contains('hidden'); + toggleTerminalBtn.setAttribute('aria-pressed', isVisible ? 'true' : 'false'); + if (isVisible) toggleTerminalBtn.classList.add('active'); + else toggleTerminalBtn.classList.remove('active'); + + if (termAutoscrollBtn && termAutoScrollCheckbox) { + termAutoscrollBtn.setAttribute('aria-pressed', termAutoScrollCheckbox.checked ? 'true' : 'false'); + termAutoscrollBtn.addEventListener('click', () => { + const newVal: boolean = !termAutoScrollCheckbox.checked; + termAutoScrollCheckbox.checked = newVal; + termAutoscrollBtn.setAttribute('aria-pressed', newVal ? 'true' : 'false'); + }); + } +} diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/modules/topBar.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/topBar.ts new file mode 100644 index 00000000000..07201249bc7 --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/topBar.ts @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/* eslint-disable @rushstack/no-new-null */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export interface ITopBarRefs { + connectBtn: HTMLElement | null; + statusPill: HTMLElement | null; + statusEmojiEl: HTMLElement | null; + debugBtn: HTMLElement | null; + verboseBtn: HTMLElement | null; + playPauseBtn: HTMLElement | null; + parallelismInput: HTMLInputElement | null; + managerStateEl: HTMLElement | null; +} + +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 | null): 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 | null, + statusEmojiEl: HTMLElement | null, + statusEmoji: (status: string) => string +): void { + if (!statusPill || !statusEmojiEl) return; + + statusPill.className = 'status-pill status-Unspecified'; + statusEmojiEl.textContent = statusEmoji('Waiting'); + statusPill.textContent = overallStatusText('Connecting'); +} + +export function updateStatusPill( + refs: ITopBarRefs, + ws: WebSocket | null, + graphSettings: any, + statusEmoji: (status: string) => string +): void { + if (!refs.statusPill || !refs.statusEmojiEl) return; + + let pillStatus: string = 'Disconnected'; + if (ws && ws.readyState === WebSocket.OPEN) { + pillStatus = graphSettings?.status || 'Unspecified'; + } + + refs.statusPill.className = ''; + refs.statusPill.classList.add('status-pill', 'status-' + pillStatus); + refs.statusEmojiEl.textContent = statusEmoji(pillStatus); + refs.statusPill.textContent = overallStatusText(pillStatus); +} + +export function setConnected( + refs: ITopBarRefs, + connected: boolean, + updateSelectionUI: () => void, + disabledControlIds: string[] +): void { + const connectBtnEl: HTMLElement | null = refs.connectBtn; + const iconSpan: HTMLElement | null = connectBtnEl + ? (connectBtnEl.querySelector('span.codicon') as HTMLElement | null) + : null; + + 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 | null = document.getElementById(id) as + | HTMLButtonElement + | HTMLInputElement + | null; + if (el) el.disabled = !connected; + }); + + updateSelectionUI(); +} + +export function updateManagerState(refs: ITopBarRefs, graphSettings: any): void { + if (!graphSettings) return; + + const { debugBtn, verboseBtn, playPauseBtn, parallelismInput, managerStateEl } = refs; + + if (debugBtn) { + if (graphSettings.debugMode) debugBtn.classList.add('active'); + else debugBtn.classList.remove('active'); + debugBtn.setAttribute('aria-pressed', graphSettings.debugMode ? 'true' : 'false'); + debugBtn.title = graphSettings.debugMode ? 'Turn off debug logging' : 'Turn on debug logging'; + } + + if (verboseBtn) { + if (graphSettings.verbose) verboseBtn.classList.add('active'); + else verboseBtn.classList.remove('active'); + verboseBtn.setAttribute('aria-pressed', graphSettings.verbose ? 'true' : 'false'); + verboseBtn.title = graphSettings.verbose ? 'Turn off verbose logging' : 'Turn on verbose logging'; + } + + const ppIcon: HTMLElement | null = playPauseBtn + ? (playPauseBtn.querySelector('.codicon') as HTMLElement | null) + : null; + if (playPauseBtn) { + if (!graphSettings.pauseNextIteration) { + playPauseBtn.classList.add('playing'); + playPauseBtn.setAttribute('aria-label', 'Switch to manual (pause)'); + playPauseBtn.title = 'Pause automatic iterations'; + if (ppIcon) { + ppIcon.classList.remove('codicon-debug-start', 'codicon-debug-continue'); + ppIcon.classList.add('codicon-debug-pause'); + } + } else { + playPauseBtn.classList.remove('playing'); + playPauseBtn.setAttribute('aria-label', 'Switch to automatic (play)'); + playPauseBtn.title = 'Resume automatic iterations'; + if (ppIcon) { + ppIcon.classList.remove('codicon-debug-pause'); + ppIcon.classList.add('codicon-debug-start'); + } + } + } + + if (parallelismInput) { + parallelismInput.value = String(graphSettings.parallelism ?? ''); + } + + if (managerStateEl) { + managerStateEl.innerHTML = ''; + } + + const executeBtn: HTMLElement | null = document.getElementById('execute-btn'); + if (executeBtn) { + if (graphSettings.hasScheduledIteration) { + executeBtn.classList.add('queued'); + executeBtn.title = 'Run once (changes detected)'; + executeBtn.setAttribute('aria-label', 'Run once (changes detected)'); + } else { + executeBtn.classList.remove('queued'); + executeBtn.title = 'Run once'; + executeBtn.setAttribute('aria-label', 'Run once'); + } + } +} diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/modules/urlState.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/urlState.ts new file mode 100644 index 00000000000..94748819a56 --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/dashboard/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/rush-plugins/rush-serve-plugin/src/dashboard/modules/viewBar.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/viewBar.ts new file mode 100644 index 00000000000..fdc844d409a --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/viewBar.ts @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/* eslint-disable @typescript-eslint/typedef */ + +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 = 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/rush-plugins/rush-serve-plugin/src/dashboard/styles/dashboard.css b/rush-plugins/rush-serve-plugin/src/dashboard/styles/dashboard.css new file mode 100644 index 00000000000..bfcc9201b1b --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/dashboard/styles/dashboard.css @@ -0,0 +1,553 @@ +/* Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. */ +/* See LICENSE in the project root for license information. */ + +:root { + --bg: #0f1115; + --panel: #1b1f27; + --panel-alt: #232a34; + --border: #2a3240; + --text: #d4d7dd; + --muted: #8692a2; + --accent: #3b82f6; + --accent-hover: #2563eb; + --danger: #ef4444; + --warn: #f59e0b; + --success: #10b981; + --rgb-accent: 59, 130, 246; + --rgb-accent-hover: 37, 99, 235; + --rgb-success: 16, 185, 129; + --rgb-warn: 245, 158, 11; + --rgb-danger: 239, 68, 68; + --rgb-white: 255, 255, 255; + --rgb-panel-grad-a: 30, 37, 48; + --rgb-panel-grad-b: 16, 19, 24; + --status-ready: #334155; + --status-waiting: #475569; + --status-queued: #475569; + --status-executing: var(--warn); + --status-success: var(--success); + --status-success-warning: #eab308; + --status-skipped: #475569; + --status-from-cache: #0ea5e9; + --status-failure: var(--danger); + --status-blocked: #475569; + --status-noop: #6366f1; + --status-aborted: #64748b; + --status-disconnected: #334155; + --scroll-track: #1b1f27; + --scroll-thumb: #334155; + --scroll-thumb-hover: #3f5166; + --radius: 6px; + font-family: + system-ui, + Segoe UI, + Roboto, + Helvetica, + Arial, + sans-serif; +} + +html, +body { + height: 100%; + margin: 0; + background: var(--bg); + color: var(--text); +} + +body { + display: flex; + flex-direction: column; +} + +h1 { + font-size: 1.2rem; + margin: 0 0 0.5rem; +} + +a { + color: var(--accent); +} + +code { + background: var(--panel-alt); + padding: 2px 4px; + border-radius: 4px; +} + +.flex-spacer { + flex: 1 1 auto; +} + +#app-title { + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.5px; + min-width: 180px; +} + +#actions { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + align-items: center; +} + +#graph-state { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; +} + +.parallelism-label { + font-size: 0.65rem; + align-items: baseline; + gap: 4px; +} + +.search-label { + display: flex; + align-items: center; + gap: 4px; +} + +#parallelism-input, +#name-search { + background: var(--panel-alt); + border: 1px solid var(--border); + color: var(--text); + border-radius: var(--radius); + padding: 2px 4px; + font-size: 0.7rem; +} + +#parallelism-input { + width: 60px; +} + +#name-search { + min-width: 140px; +} + +#connection-form { + gap: 0.4rem; +} + +.content-wrap { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + +#main { + flex: 1; + display: flex; + min-height: 0; +} + +#view-controls { + display: flex; + gap: 0.75rem; + align-items: center; + font-size: 0.7rem; +} + +#view-controls label { + display: flex; + gap: 0.25rem; + align-items: center; + cursor: pointer; +} + +select, +#filter-select { + background: var(--panel-alt); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2px 4px; + font-size: 0.7rem; +} + +select:disabled { + opacity: 0.6; +} + +#connection-form input { + background: var(--panel-alt); + border: 1px solid var(--border); + color: var(--text); + padding: 0.35rem 0.5rem; + border-radius: var(--radius); +} + +#connection-form button.action { + padding: 0.4rem 0.75rem; + border-radius: var(--radius); + font-weight: 600; + cursor: pointer; +} + +#connection-form button.icon-btn { + background: transparent; + border: none; + color: inherit; + padding: 0; +} + +button:not(:disabled) { + cursor: pointer; +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +button.action { + background: var(--panel-alt); + color: var(--text); + border: 1px solid var(--border); + padding: 0.35rem 0.6rem; + border-radius: var(--radius); + font-size: 0.8rem; +} + +button.action:hover:not(:disabled) { + background: #2d3744; +} + +button.primary, +.icon-btn.primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +button.primary:hover:not(:disabled), +.icon-btn.primary:hover:not(:disabled) { + background: var(--accent-hover); +} + +.icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + font-size: 16px; + line-height: 1; + background: transparent; + border: none; + color: var(--success); + position: relative; +} + +.icon-btn:hover:not(:disabled) { + filter: brightness(1.15); +} + +.icon-btn.stop { + color: var(--danger); +} + +.icon-btn.stop:hover:not(:disabled) { + filter: brightness(1.15); +} + +.icon-btn.playing { + color: var(--accent); +} + +.icon-btn.playing:hover:not(:disabled) { + color: var(--accent-hover); + filter: none; +} + +.icon-btn:disabled { + opacity: 0.45; +} + +.icon-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 4px; +} + +.icon-btn.queued::after { + content: ''; + position: absolute; + width: 18px; + height: 18px; + border: 2px dashed var(--warn); + border-radius: 50%; + animation: queuedPulse 1.2s linear infinite; + pointer-events: none; +} + +.icon-btn.toggle { + color: var(--muted); +} + +.icon-btn.toggle:hover:not(.active):not(:disabled) { + color: var(--text); +} + +.icon-btn.toggle.active { + color: var(--accent); +} + +.icon-btn.toggle.active:hover:not(:disabled) { + color: var(--accent-hover); +} + +@keyframes queuedPulse { + 0% { + opacity: 0.2; + } + 50% { + opacity: 0.9; + } + 100% { + opacity: 0.2; + } +} + +.codicon { + font-size: 18px; + line-height: 1; +} + +.icon-btn .codicon { + pointer-events: none; +} + +.icon-btn.stop .codicon { + font-size: 17px; +} + +.icon-btn.queued .icon { + transform: scale(1.05); +} + +button.danger { + background: var(--danger); + border-color: var(--danger); + color: #fff; +} + +button.danger:hover:not(:disabled) { + filter: brightness(1.1); +} + +#left, +#right { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +#left { + border-right: 1px solid var(--border); +} + +.section { + padding: 0.75rem 0.75rem 0.25rem; + background: var(--panel); + border-bottom: 1px solid var(--border); +} + +.section h2 { + margin: 0 0 0.5rem; + font-size: 0.95rem; + letter-spacing: 0.5px; + font-weight: 600; +} + +.status-pill { + padding: 2px 6px; + border-radius: 8px; + font-weight: 600; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.5px; + display: inline-block; + width: 110px; + text-align: center; + box-sizing: border-box; +} + +.status-Ready { + background: var(--status-ready); +} + +.status-Waiting { + background: var(--status-waiting); +} + +.status-Queued { + background: var(--status-queued); +} + +.status-Executing { + background: var(--warn); + color: #000; +} + +.status-Success { + background: var(--success); + color: #000; +} + +.status-SuccessWithWarning { + background: var(--status-success-warning); + color: #000; +} + +.status-FromCache { + background: var(--status-from-cache); + color: #000; +} + +.status-Failure { + background: var(--danger); +} + +.status-Blocked, +.status-Skipped { + background: var(--status-skipped); +} + +.status-NoOp { + background: var(--status-noop); +} + +.status-Aborted { + background: var(--status-aborted); +} + +.status-Canceled { + background: var(--status-aborted); +} + +.status-Unspecified { + background: var(--status-ready); +} + +.status-Unknown { + background: var(--status-ready); + opacity: 0.85; +} + +.status-Disconnected { + background: var(--status-disconnected); +} + +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; +} + +.badge { + background: #334155; + padding: 2px 4px; + font-size: 0.55rem; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 600; +} + +.badge.silent { + background: #4b5563; +} + +.badge.active { + background: var(--accent); +} + +.badge.disabled { + background: #64748b; +} + +.badge.local-only { + background: var(--warn); + color: #000; +} + +.badge.not-running { + background: #1e293b; + border: 1px dashed #475569; +} + +#bottom-bar { + background: var(--panel); + border-top: 1px solid var(--border); + padding: 0.3rem 0.6rem; + display: flex; + gap: 1rem; + font-size: 0.6rem; + align-items: center; +} + +.pill { + background: var(--panel-alt); + padding: 2px 6px; + border-radius: 12px; +} + +.flex-row { + display: flex; + gap: 0.4rem; + align-items: center; +} + +input[type='number'] { + width: 70px; +} + +.icon-btn.active, +.icon-btn.toggle.active { + color: var(--accent); +} + +@media (max-width: 1200px) { + #main { + flex-direction: column; + } + + #left { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +* { + scrollbar-color: var(--scroll-thumb) var(--scroll-track); + scrollbar-width: thin; +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: var(--scroll-track); +} + +::-webkit-scrollbar-thumb { + background: var(--scroll-thumb); + border: 2px solid var(--scroll-track); + border-radius: 8px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--scroll-thumb-hover); +} + +::-webkit-scrollbar-corner { + background: var(--scroll-track); +} diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/styles/graph-view.css b/rush-plugins/rush-serve-plugin/src/dashboard/styles/graph-view.css new file mode 100644 index 00000000000..693f0796556 --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/dashboard/styles/graph-view.css @@ -0,0 +1,449 @@ +/* Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. */ +/* See LICENSE in the project root for license information. */ + +#graph-container { + position: relative; + flex: 1; + min-height: 0; + display: flex; + flex-direction: row; + align-items: stretch; + gap: 0; +} + +#graph { + width: 100%; + height: 100%; + position: relative; + overflow: auto; + background: radial-gradient(circle at 25% 20%, #1e2530, #101318); +} + +#phase-pane { + width: 220px; + background: var(--panel); + border-right: 1px solid var(--border); + overflow: hidden; + font-size: 0.65rem; + padding: 0.4rem 0.5rem 0.6rem; + box-sizing: border-box; + position: relative; +} + +#phase-pane h3 { + margin: 0 0 0.4rem; + font-size: 0.68rem; + letter-spacing: 0.5px; + text-transform: uppercase; + opacity: 0.8; +} + +.phase-group { + padding: 0.4rem 0.45rem 0.45rem; + background: var(--panel-alt); + border: 1px solid var(--border); + border-radius: 4px; + display: flex; + flex-direction: column; + min-height: 0; +} + +#phase-pane .phase-groups { + display: flex; + flex-direction: column; + gap: 0.6rem; + height: calc(100% - 1.2rem); + overflow: hidden; +} + +#phase-pane .phase-groups > .phase-group { + flex: 1 1 0; +} + +.phase-header { + display: flex; + align-items: center; + gap: 0.35rem; + font-weight: 600; + margin-bottom: 0.3rem; + font-size: 0.66rem; + padding: 2px 4px 3px; + background: #27303c; + border: 1px solid var(--border); + border-radius: 4px; +} + +.phase-status-emoji { + font-size: 0.8rem; + line-height: 1; +} + +.phase-name { + flex: 1; +} + +.phase-problems { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; + overflow-y: auto; + flex: 1 1 auto; + min-height: 0; +} + +.phase-problems li { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.phase-problems a { + color: var(--accent); + text-decoration: none; +} + +.phase-problems a:hover { + text-decoration: underline; +} + +.phase-problem-emoji { + font-size: 0.75rem; +} + +.phase-pane-empty { + opacity: 0.6; + font-style: italic; + font-size: 0.6rem; +} + +#graph-wrapper { + flex: 1; + min-height: 0; + position: relative; +} + +#graph-legend { + position: absolute; + right: 28px; + bottom: 28px; + background: rgba(15, 17, 21, 0.9); + border: 1px solid var(--border); + border-radius: 6px; + padding: 4px 6px 6px; + font-size: 0.54rem; + line-height: 1.1; + max-width: 420px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4); + pointer-events: auto; +} + +#graph-legend .legend-columns { + display: flex; + gap: 10px; + align-items: flex-start; +} + +#graph-legend .legend-col:first-child { + flex: 0 0 130px; +} + +#graph-legend .legend-col:last-child { + flex: 1 1 auto; +} + +#graph-legend.collapsed { + width: auto; + max-width: none; + padding: 4px 6px; +} + +#graph-legend.collapsed .legend-columns { + display: none; +} + +#graph-legend .legend-col { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +#graph-legend .legend-heading { + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; + opacity: 0.75; + font-size: 0.58rem; + margin: 0 0 4px; +} + +#graph-legend .legend-row { + display: flex; + align-items: center; + gap: 5px; + min-width: 0; +} + +#graph-legend .legend-label-wrap { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +#graph-legend .legend-row small { + font-size: 0.5rem; + opacity: 0.7; +} + +#graph-legend .legend-subheading { + margin: 4px 0 2px; + font-size: 0.52rem; + text-transform: uppercase; + letter-spacing: 0.5px; + opacity: 0.65; + font-weight: 600; +} + +#graph-legend h4 { + margin: 0 0 4px; + font-size: 0.6rem; + letter-spacing: 0.6px; + text-transform: uppercase; + opacity: 0.8; + display: flex; + align-items: center; + gap: 4px; +} + +.legend-emoji { + font-size: 0.7rem; + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid var(--border); + border-radius: 6px; + background: #1f2530; + box-sizing: border-box; +} + +.legend-enabled-sample { + position: relative; + width: 18px; + height: 18px; + border: 2px solid var(--border); + border-radius: 6px; + background: #1f2530; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + box-sizing: border-box; +} + +.legend-enabled-sample .sub { + position: absolute; + bottom: 0; + right: 0; + transform: translate(50%, 50%); + font-size: 9px; + line-height: 1; + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.55)); + pointer-events: none; +} + +.legend-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +#graph svg { + position: absolute; + top: 0; + left: 0; + overflow: visible; + pointer-events: none; +} + +.graph-marquee { + position: absolute; + border: 1px solid var(--accent); + background: rgba(var(--rgb-accent), 0.15); + pointer-events: none; + z-index: 5; + box-shadow: 0 0 0 1px rgba(var(--rgb-accent), 0.4) inset; +} + +.op-node { + position: absolute; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + line-height: 1; + border: 2px solid var(--border); + border-radius: 6px; + background: #1f2530; + box-sizing: border-box; + user-select: none; + cursor: pointer; + transition: + border-color 0.15s ease, + transform 0.05s ease; +} + +.op-node .enabled-indicator { + position: absolute; + bottom: 0; + right: 0; + transform: translate(50%, 50%); + font-size: 11px; + line-height: 1; + pointer-events: none; + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.55)); + transition: opacity 0.15s ease; +} + +.op-node.selected { + box-shadow: 0 0 0 2px var(--accent); +} + +.op-node:hover { + transform: scale(1.08); +} + +.op-node .active-indicator { + position: absolute; + bottom: 0; + left: 0; + transform: translate(-50%, 50%); + font-size: 12px; + line-height: 1; + pointer-events: none; + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.55)); +} + +.op-node .pending-indicator { + position: absolute; + top: 0; + left: 0; + transform: translate(-50%, -50%); + font-size: 12px; + line-height: 1; + pointer-events: none; + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.55)); +} + +.op-node.filtered-out .active-indicator, +.op-node.filtered-out-search .active-indicator, +.op-node.not-running .active-indicator, +.op-node.filtered-out.not-running .active-indicator { + opacity: 1 !important; +} + +.op-node .name { + font-weight: 600; +} + +.op-node .pkg { + color: var(--muted); + font-size: 0.6rem; +} + +.op-node .badges { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.op-node .enabled-indicator { + opacity: 1; +} + +.op-node.not-running .enabled-indicator { + opacity: 0.5; +} + +.op-node.filtered-out .enabled-indicator { + opacity: 0.25; +} + +.op-node.filtered-out-search .enabled-indicator { + opacity: 0.15; +} + +.op-node.filtered-out.not-running .enabled-indicator { + opacity: 0.25; +} + +.op-node .emoji { + transition: opacity 0.15s ease; +} + +.op-node.filtered-out .emoji { + opacity: 0.25; +} + +.op-node.filtered-out-search .emoji { + opacity: 0.15; +} + +.op-node.not-running .emoji { + opacity: 0.5; +} + +.op-node.filtered-out.not-running .emoji { + opacity: 0.25; +} + +.op-node.dashed { + border-style: dashed; +} + +.op-node.dotted { + border-style: dotted; +} + +.edge { + stroke-width: 1.2; + fill: none; +} + +.edge.dashed { + stroke-dasharray: 5 4; +} + +.edge.dotted { + stroke-dasharray: 2 4; +} + +.edge.filtered-out { + opacity: 0.35; +} + +.edge.filtered-out-search { + opacity: 0.2; +} + +.edge.highlight { + stroke: var(--accent); + stroke-width: 2; +} + +.edge.dim { + opacity: 0.28; + filter: brightness(0.8); +} + +.edge.not-running { + opacity: 0.55; +} diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/styles/selection-bar.css b/rush-plugins/rush-serve-plugin/src/dashboard/styles/selection-bar.css new file mode 100644 index 00000000000..71253cf0914 --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/dashboard/styles/selection-bar.css @@ -0,0 +1,56 @@ +/* Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. */ +/* See LICENSE in the project root for license information. */ + +#selection-heading { + font-weight: 600; + letter-spacing: 0.5px; + font-size: 0.7rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +#selection-actions { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; + align-items: center; +} + +/* Selection bar (hidden when no selection) */ +#selection-bar { + background: var(--panel-alt); + border-bottom: 1px solid var(--border); + padding: 0.35rem 0.6rem; + font-size: 0.65rem; + display: flex; + gap: 0.6rem; + align-items: center; + flex-wrap: wrap; +} + +#selection-actions button.action { + font-size: 0.65rem; +} + +#selection-bar .action { + line-height: 1.1; +} + +#selection-bar button.action, +#selection-bar .selection-count-btn { + height: 30px; + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.65rem; +} + +#selection-bar .selection-count-btn[disabled] { + opacity: 0.6; + cursor: default; +} + +#selection-bar[aria-hidden='true'] { + display: none; +} diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/styles/table-view.css b/rush-plugins/rush-serve-plugin/src/dashboard/styles/table-view.css new file mode 100644 index 00000000000..462bdd370b3 --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/dashboard/styles/table-view.css @@ -0,0 +1,108 @@ +/* Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. */ +/* See LICENSE in the project root for license information. */ + +#operations-table-container { + flex: 1; + min-height: 0; + overflow: auto; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.75rem; +} + +thead { + position: sticky; + top: 0; + background: var(--panel-alt); + z-index: 2; +} + +th, +td { + padding: 0.35rem 0.5rem; + border-bottom: 1px solid var(--border); + text-align: left; + vertical-align: top; +} + +tbody tr { + cursor: pointer; +} + +tbody tr.selected { + background: rgba(var(--rgb-accent), 0.15); +} + +tbody tr:hover { + background: rgba(var(--rgb-white), 0.05); +} + +td.pivot-cell.selected { + background: rgba(var(--rgb-accent), 0.22); +} + +tr.selected > td.pkg-cell { + background: rgba(var(--rgb-accent), 0.18); +} + +/* Prevent brief native text highlight flash during selection interactions */ +td.pivot-cell, +td.pkg-cell { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; +} + +th.sortable { + user-select: none; +} + +th.sortable span.sort-indicator { + opacity: 0.5; + margin-left: 4px; +} + +.status-row { + display: flex; + gap: 4px; + align-items: center; + flex-wrap: wrap; +} + +.status-row .badges { + display: flex; + gap: 4px; + flex-wrap: wrap; + margin-left: 4px; +} + +td.status-cell { + white-space: nowrap; +} + +td.status-cell .status-pill { + margin-left: 4px; +} + +.pivot-enabled { + margin-left: 4px; + font-size: 0.75rem; +} + +.pivot-active { + margin-left: 4px; + font-size: 0.75rem; + position: relative; + top: 1px; +} + +#table-stats { + font-size: 0.6rem; + color: var(--muted); + padding: 0.25rem 0.75rem 0.5rem; + background: var(--panel); + border-top: 1px solid var(--border); +} diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/styles/terminal-pane.css b/rush-plugins/rush-serve-plugin/src/dashboard/styles/terminal-pane.css new file mode 100644 index 00000000000..3c80f1561c6 --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/dashboard/styles/terminal-pane.css @@ -0,0 +1,113 @@ +/* Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. */ +/* See LICENSE in the project root for license information. */ + +#resizer { + width: 6px; + cursor: col-resize; + background: linear-gradient(90deg, transparent, rgba(var(--rgb-accent), 0.06), transparent); + transition: background 0.12s ease; +} + +#resizer:hover { + background: linear-gradient(90deg, transparent, rgba(var(--rgb-accent), 0.12), transparent); +} + +#term-autoscroll { + display: none; +} + +.terminal-container { + background: #000; + border-left: 1px solid var(--border); + color: var(--text); + font-size: 0.75rem; + display: flex; + flex-direction: column; + align-self: stretch; + flex: 0 0 360px; + width: 360px; + min-width: 36px; + transition: + width 0.18s ease, + box-shadow 0.18s ease; +} + +.terminal-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 6px 8px; + border-bottom: 1px solid var(--border); +} + +.terminal-title { + font-weight: 600; + font-size: 0.8rem; +} + +.terminal-controls { + margin-left: auto; + display: flex; + gap: 6px; + align-items: center; +} + +.terminal-container.hidden { + display: none !important; +} + +#resizer.hidden { + display: none !important; +} + +.terminal-body { + flex: 1 1 auto; + padding: 8px; + overflow: auto; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; + white-space: pre; + font-size: 12px; + line-height: 1.25; +} + +.term-chunk.stdout { + color: var(--text); +} + +.term-chunk.stderr { + color: var(--danger); +} + +.terminal-controls button.icon-btn { + width: auto; + height: auto; + padding: 4px 8px; +} + +.terminal-container.term-flash .terminal-header { + animation: termFlash 300ms ease-in-out; +} + +/* Ensure the top-bar terminal icon specifically uses accent when active */ +#toggle-terminal-btn.active, +#toggle-terminal-btn.active .codicon { + color: var(--accent) !important; + fill: var(--accent) !important; +} + +.codicon.vertical { + display: inline-block; + transform: rotate(90deg); +} + +@keyframes termFlash { + 0% { + box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); + } + 30% { + box-shadow: 0 0 8px 2px rgba(59, 130, 246, 0.18); + } + 100% { + box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); + } +} diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/styles/top-bar.css b/rush-plugins/rush-serve-plugin/src/dashboard/styles/top-bar.css new file mode 100644 index 00000000000..e8ad859010d --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/dashboard/styles/top-bar.css @@ -0,0 +1,112 @@ +/* Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. */ +/* See LICENSE in the project root for license information. */ + +/* Scoped styles for the dashboard top bar component */ +#top-bar { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 0.75rem; + align-items: baseline; + padding: 0.5rem 0.75rem; + background: var(--panel); + border-bottom: 1px solid var(--border); + max-height: 40vh; + overflow-y: auto; +} + +#top-bar > * { + display: flex; + align-items: baseline; +} + +#top-bar .icon-btn { + width: auto; + height: auto; + padding: 0 4px; + font-size: 0.8rem; + line-height: 1.05; + align-items: baseline; +} + +#top-bar .icon-btn .codicon, +#top-bar .icon-btn span[aria-hidden='true'] { + position: relative; + top: 2px; + line-height: 1; +} + +#top-bar #actions, +#top-bar #graph-state, +#top-bar #view-controls, +#top-bar #overall-status { + align-items: baseline; +} + +#top-bar #overall-status { + display: flex; + align-items: center; +} + +#status-emoji { + position: relative; + top: 1px; + font-size: 1rem; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; +} + +#status-pill { + padding: 2px 10px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 600; + line-height: 1.05; +} + +/* Connected (disconnect) state = red foreground */ +#top-bar #connection-form #connect-btn[data-state='connected'] { + color: var(--danger); +} + +/* Disconnected state = accent (blue) foreground */ +#top-bar #connection-form #connect-btn[data-state='disconnected'] { + color: var(--accent); +} + +/* Remove default light-mode spinner arrows and supply dark replacements */ +#top-bar input[type='number']::-webkit-inner-spin-button, +#top-bar input[type='number']::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +#top-bar input[type='number'] { + background-image: + linear-gradient(135deg, var(--muted) 50%, transparent 50%), + linear-gradient(315deg, var(--muted) 50%, transparent 50%), + linear-gradient(45deg, var(--muted) 50%, transparent 50%), + linear-gradient(225deg, var(--muted) 50%, transparent 50%); + background-size: + 6px 6px, + 6px 6px, + 6px 6px, + 6px 6px; + background-position: + calc(100% - 10px) 6px, + calc(100% - 6px) 6px, + calc(100% - 10px) calc(100% - 6px), + calc(100% - 6px) calc(100% - 6px); + background-repeat: no-repeat; + padding-right: 20px; + appearance: textfield; + -moz-appearance: textfield; +} + +#top-bar input[type='number']:focus { + outline: 1px solid var(--accent); + outline-offset: 0; +} diff --git a/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts b/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts index a135c7aec04..40c4d42b7c5 100644 --- a/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts +++ b/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts @@ -195,6 +195,7 @@ export async function phasedCommandHandler(options: IPhasedCommandHandlerOptions servePath, express.static(diskPath, { dotfiles: 'ignore', + extensions: ['js'], immutable: rule.immutable, index: false, redirect: false, diff --git a/rush-plugins/rush-serve-plugin/tsconfig.json b/rush-plugins/rush-serve-plugin/tsconfig.json index f760fdced03..59112f12dad 100644 --- a/rush-plugins/rush-serve-plugin/tsconfig.json +++ b/rush-plugins/rush-serve-plugin/tsconfig.json @@ -2,6 +2,7 @@ "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json", "compilerOptions": { + "lib": ["ES2022", "DOM"], // express hackery makes this necessary "skipLibCheck": true }