From e723169fe2a911237ed512df55668dbf9b6b3b46 Mon Sep 17 00:00:00 2001 From: Mo Jazayeri Date: Wed, 22 Jul 2026 02:06:35 +0000 Subject: [PATCH 01/12] Add rush-serve plugin dashboard UI and config updates --- .../rush-plugins/rush-serve-plugin.json | 9 +- rush-plugins/rush-serve-plugin/README.md | 19 + .../rush-serve-plugin/config/heft.json | 16 + .../rush-serve-plugin/eslint.config.js | 19 + .../src/dashboard/dashboard.css | 1268 +++++++++ .../src/dashboard/dashboard.html | 248 ++ .../src/dashboard/dashboard.ts | 2399 +++++++++++++++++ 7 files changed, 3977 insertions(+), 1 deletion(-) create mode 100644 rush-plugins/rush-serve-plugin/src/dashboard/dashboard.css create mode 100644 rush-plugins/rush-serve-plugin/src/dashboard/dashboard.html create mode 100644 rush-plugins/rush-serve-plugin/src/dashboard/dashboard.ts diff --git a/common/config/rush-plugins/rush-serve-plugin.json b/common/config/rush-plugins/rush-serve-plugin.json index 8d24cc8cc26..e2a1eacd618 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-commonjs/dashboard", + "servePath": "/dashboard", + "immutable": false + } + ] } diff --git a/rush-plugins/rush-serve-plugin/README.md b/rush-plugins/rush-serve-plugin/README.md index 11180aecaef..c51c5789378 100644 --- a/rush-plugins/rush-serve-plugin/README.md +++ b/rush-plugins/rush-serve-plugin/README.md @@ -22,6 +22,25 @@ 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 built assets are emitted to `lib-commonjs/dashboard/`. Because the HTML references sibling `dashboard.css` and `dashboard.ts` files, serve the built 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-commonjs/dashboard", + "servePath": "/dashboard", + "immutable": false + } + ] +} +``` + +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.css b/rush-plugins/rush-serve-plugin/src/dashboard/dashboard.css new file mode 100644 index 00000000000..01ab1bd4b93 --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/dashboard/dashboard.css @@ -0,0 +1,1268 @@ +/* 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 triplets for alpha blending (previously omitted) */ + --rgb-accent: 59, 130, 246; /* matches --accent */ + --rgb-accent-hover: 37, 99, 235; /* matches --accent-hover */ + --rgb-success: 16, 185, 129; /* matches --success */ + --rgb-warn: 245, 158, 11; /* matches --warn */ + --rgb-danger: 239, 68, 68; /* matches --danger */ + --rgb-white: 255, 255, 255; /* white for subtle overlays */ + --rgb-panel-grad-a: 30, 37, 48; /* #1e2530 */ + --rgb-panel-grad-b: 16, 19, 24; /* #101318 */ + /* Status semantic hues */ + --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; + /* Graph / scrollbar backgrounds */ + --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; +} +#top-bar { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 0.75rem; + align-items: baseline; /* baseline alignment for tighter visual rhythm */ + padding: 0.5rem 0.75rem; + background: var(--panel); + border-bottom: 1px solid var(--border); + max-height: 40vh; + overflow-y: auto; +} +/* Ensure all immediate children share a consistent baseline context */ +#top-bar > * { + display: flex; + align-items: baseline; +} +/* Top bar specific baseline alignment adjustments */ +#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; /* nudge glyphs down to sit on baseline visually */ + line-height: 1; +} +/* Connected (disconnect) state = red foreground */ +#connection-form #connect-btn[data-state='connected'] { + color: var(--danger); +} +/* Disconnected state = accent (blue) foreground */ +#connection-form #connect-btn[data-state='disconnected'] { + color: var(--accent); +} +/* Align abort-session emoji exactly with disconnect icon */ +/* Dark mode spin buttons for number input */ +/* Remove default light-mode spinner arrows and supply subtle 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; +} +/* Custom spinner using background gradients (up/down triangles) */ +#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 !important; /* space for custom arrows */ +} +#top-bar input[type='number'] { + appearance: textfield; + -moz-appearance: textfield; /* Firefox remove default */ +} +#top-bar input[type='number']:focus { + outline: 1px solid var(--accent); + outline-offset: 0; +} +/* Let large groups (actions, graph-state, view-controls) keep their internal layouts */ +#actions, +#graph-state, +#view-controls { + align-items: baseline; +} +/* Normalize status pill and emoji to align with surrounding text */ +#overall-status { + align-items: baseline; +} +#status-emoji { + position: relative; + top: 1px; +} +#status-pill { + line-height: 1.05; +} +.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; +} +#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; +} +.content-wrap { + display: flex; + flex: 1; + min-height: 0; +} +#main { + flex: 1; + display: flex; + min-height: 0; +} +#resizer { + width: 6px; + cursor: col-resize; + background: transparent; +} +#term-autoscroll { + display: none; +} +#overall-status { + display: flex; + align-items: center; +} +#status-emoji { + font-size: 1rem; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; +} +#status-pill { + padding: 2px 10px 2px 10px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 600; + line-height: 1; +} +#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; +} +/* Unified select styling */ +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); +} +/* 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 { + align-items: center; +} +#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; +} +/* Base action buttons inside selection bar (leave icon buttons separate) */ +#connection-form button.action, +.icon-btn { + padding: 0.4rem 0.75rem; + border-radius: var(--radius); + #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-bar[aria-hidden='true'] { + display: none; + } + #selection-actions button.action { + font-size: 0.65rem; + } + font-weight: 600; + cursor: pointer; +} +/* Remove previous special backgrounds for connection buttons; icon-btn handles visuals */ +#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; + /* Default state = manual (showing play icon) -> green */ + 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); +} +/* Automatic mode (showing pause icon) -> blue accent */ +.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; +} +/* Toggle buttons */ +.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; + } +} +/* Stylable icons (avoid emoji rendering differences) */ +/* Codicon adjustments */ +.codicon { + font-size: 18px; + line-height: 1; +} +.icon-btn .codicon { + pointer-events: none; +} +.icon-btn.stop .codicon { + font-size: 17px; +} +/* Slight size boost when queued */ +.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); +} +#main { + flex: 1; + display: flex; + min-height: 0; +} +#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; +} +#actions { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} +#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-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); +} +/* Newly introduced Unknown status for operations that have never executed */ +.status-Unknown { + background: var(--status-ready); + opacity: 0.85; +} +/* Disconnected (top-level pill only) should share a neutral background */ +.status-Disconnected { + background: var(--status-disconnected); +} +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; +} +#graph-container { + position: relative; + flex: 1; + min-height: 0; +} +#graph { + width: 100%; + height: 100%; + position: relative; + overflow: auto; + background: radial-gradient(circle at 25% 20%, #1e2530, #101318); +} +/* Phase summary pane + graph layout */ +#graph-container { + display: flex; + flex-direction: row; + align-items: stretch; + gap: 0; +} +#phase-pane { + width: 220px; + background: var(--panel); + border-right: 1px solid var(--border); + /* Prevent overall pane scrollbar; internal lists handle scrolling */ + 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 { + /* With flex distribution each phase takes equal vertical space */ + 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; /* allow shrinking */ +} +/* Container holding phase groups: turn into flex column with uniform distribution */ +#phase-pane .phase-groups { + display: flex; + flex-direction: column; + gap: 0.6rem; + height: calc(100% - 1.2rem); /* subtract header + padding roughly */ + overflow: hidden; /* groups themselves don't scroll */ +} +#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; /* interactive now (collapse button) */ +} +#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); /* updated dynamically */ + border-radius: 6px; + background: #1f2530; + box-sizing: border-box; +} +/* Legend sample showing subscript placement of enabled state glyph */ +.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; /* base (not used directly now) */ + 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; +} +/* Drag selection marquee */ +.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); /* color updated dynamically per status */ + border-radius: 6px; + background: #1f2530; + box-sizing: border-box; + user-select: none; + cursor: pointer; + transition: + border-color 0.15s ease, + transform 0.05s ease; +} +/* Superscript enabled-state indicator (traffic light) */ +.op-node .enabled-indicator { + position: absolute; + bottom: 0; + right: 0; + transform: translate(50%, 50%); /* center over corner */ + 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; +} +/* Emoji inherits dimming via node classes */ +.op-node.selected { + box-shadow: 0 0 0 2px var(--accent); +} +.op-node:hover { + transform: scale(1.08); +} +/* Active indicator: rocket emoji bottom-left */ +.op-node .active-indicator { + position: absolute; + bottom: 0; + left: 0; + transform: translate(-50%, 50%); /* center over corner */ + font-size: 12px; + line-height: 1; + pointer-events: none; + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.55)); +} +/* Pending changes indicator: clock emoji top-left */ +.op-node .pending-indicator { + position: absolute; + top: 0; + left: 0; + transform: translate(-50%, -50%); /* center over corner */ + font-size: 12px; + line-height: 1; + pointer-events: none; + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.55)); +} +/* Never dim active indicator; ensure full opacity */ +.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; +} +/* Queued for next iteration indicator (pulse removed) */ +/* queued-next styling removed (no visual border effect retained) */ +.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; +} +/* Enabled indicator should follow same dimming semantics as main emoji */ +.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; +} +.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; +} +.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; +} +/* Node emoji fade (background always opaque). We'll inject a span.emoji inside each node. */ +.op-node .emoji { + transition: opacity 0.15s ease; +} +.op-node.filtered-out .emoji { + opacity: 0.25; +} +/* Search filtering: a bit stronger fade so user can distinguish from status filter */ +.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 and marker assigned dynamically per status; removing defaults eliminates shadow/misaligned base color */ + 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; +} +/* Dim edges that are not executing or failed to de-emphasize passive links */ +.edge.dim { + opacity: 0.28; + filter: brightness(0.8); +} +.edge.not-running { + opacity: 0.55; +} +.pivot-enabled { + margin-left: 4px; + font-size: 0.75rem; +} +.pivot-active { + margin-left: 4px; + font-size: 0.75rem; + position: relative; + top: 1px; +} +#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; +} +#graph-state { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} +.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; +} +#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); +} +@media (max-width: 1200px) { + #main { + flex-direction: column; + } + #left { + border-right: none; + border-bottom: 1px solid var(--border); + } +} +/* Dark scrollbars (WebKit) */ +* { + scrollbar-color: var(--scroll-thumb) var(--scroll-track); /* Firefox */ + 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); +} +/* Terminal pane */ +/* Side terminal pane (right side). We'll position fixed and adjust main margin when open. */ +.terminal-container { + background: #000; /* default terminal background: black */ + border-left: 1px solid var(--border); + color: var(--text); + font-size: 0.75rem; + display: flex; + flex-direction: column; + align-self: stretch; + /* Use a fixed flex-basis so layout respects terminal width */ + 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; +} +/* The terminal pane is always fully expanded when visible; minimized/collapsed + behavior has been removed in favor of top-bar show/hide. */ +/* Content wrap allows browser to layout main + resizer + terminal naturally */ +.content-wrap { + display: flex; + flex: 1 1 0; + min-height: 0; + overflow: hidden; +} +#resizer { + 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); +} +/* When terminal is controlled via top-bar toggle, the header minimize button is not used. */ +/* Hidden state: remove terminal and resizer from layout */ +.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; /* preserve exact whitespace and line breaks from source; do NOT auto-wrap */ + 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; +} +/* Active icon buttons (e.g. verbose, terminal) use accent color */ +.icon-btn.active, +.icon-btn.toggle.active { + color: var(--accent); +} +/* Ensure the top-bar terminal icon specifically uses accent when active (higher specificity) */ +#toggle-terminal-btn.active, +#toggle-terminal-btn.active .codicon { + color: var(--accent) !important; + fill: var(--accent) !important; +} +/* Rotate an icon to indicate vertical scrolling */ +.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/dashboard.html b/rush-plugins/rush-serve-plugin/src/dashboard/dashboard.html new file mode 100644 index 00000000000..1c824970d88 --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/dashboard/dashboard.html @@ -0,0 +1,248 @@ + + + + + + + + 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..e85094a4679 --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/dashboard/dashboard.ts @@ -0,0 +1,2399 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// @ts-nocheck + +// Removed in-page log panel; logging now goes directly to devtools console +const statusPill = document.getElementById('status-pill'); +const statusEmojiEl = document.getElementById('status-emoji'); +const connectBtn = document.getElementById('connect-btn'); +const appTitleEl = document.getElementById('app-title'); +const tableEl = document.getElementById('operations-table'); +const tableHead = tableEl.querySelector('thead'); +const tableBody = tableEl.querySelector('tbody'); +const tableStats = document.getElementById('table-stats'); +const managerStateEl = document.getElementById('graph-state'); +const edgesSvg = document.getElementById('edges'); +const graphEl = document.getElementById('graph'); +const legendEl = document.getElementById('graph-legend'); +const phasePaneEl = document.getElementById('phase-pane'); +const phaseGroupsEl = phasePaneEl ? phasePaneEl.querySelector('.phase-groups') : null; +const playPauseBtn = document.getElementById('play-pause-btn'); +const parallelismInput = document.getElementById('parallelism-input'); +const debugBtn = document.getElementById('debug-btn'); +const verboseBtn = document.getElementById('verbose-btn'); +const selectVisibleBtn = document.getElementById('select-visible-btn'); +// Terminal elements +const terminalEl = document.getElementById('terminal'); +const terminalBody = document.getElementById('terminal-body'); +const termClearBtn = document.getElementById('term-clear-btn'); +const termAutoScroll = document.getElementById('term-autoscroll'); +// Terminal styling state parsed from VT SGR sequences. We'll maintain a stack-like currentState object +const ansiState = { + bold: false, + underline: false, + inverse: false, + fg: null, // CSS color string or null + bg: null +}; + +// Basic SGR parser: handles ESC [ ... m sequences. Returns array of SGR numeric params. +function parseSgrParams(seq) { + // seq expected like '\u001b[31;1m' or '31;1' + const s = seq.replace(/^\x1b\[|m$/g, ''); + if (!s) return [0]; + return s.split(';').map((p) => Number(p || 0)); +} + +// Map common SGR codes to actions that mutate ansiState. We only support common color codes and attributes. +function applySgr(params) { + if (!params || !params.length) params = [0]; + for (const p of params) { + if (p === 0) { + ansiState.bold = false; + ansiState.underline = false; + ansiState.inverse = false; + ansiState.fg = null; + ansiState.bg = null; + } else if (p === 1) { + ansiState.bold = true; + } else if (p === 4) { + ansiState.underline = true; + } else if (p === 7) { + ansiState.inverse = true; + } else if (p === 22) { + ansiState.bold = false; + } else if (p === 24) { + ansiState.underline = false; + } else if (p >= 30 && p <= 37) { + ansiState.fg = sgrColorToCss(p - 30, false); + } else if (p === 39) { + ansiState.fg = null; + } else if (p >= 40 && p <= 47) { + ansiState.bg = sgrColorToCss(p - 40, false); + } else if (p === 49) { + ansiState.bg = null; + } else if (p >= 90 && p <= 97) { + ansiState.fg = sgrColorToCss(p - 90, true); + } else if (p >= 100 && p <= 107) { + ansiState.bg = sgrColorToCss(p - 100, true); + } else { + // ignore other codes (including 38/48 extended color sequences for now) + } + } +} + +function sgrColorToCss(idx, bright) { + // Map 0..7 to CSS colors roughly matching common terminal palette + const base = [ + '#000000', // black + '#a00', // red + '#0a0', // green + '#aa0', // yellow + '#00a', // blue + '#a0a', // magenta + '#0aa', // cyan + '#ddd' // white / light gray + ]; + const brightMap = ['#555', '#ff5555', '#55ff55', '#ffff55', '#5555ff', '#ff55ff', '#55ffff', '#fff']; + return bright ? brightMap[idx] || base[idx] : base[idx] || null; +} + +// Process ANSI control sequences (CSI sequences) and update ansiState for SGRs. +// Returns an array of segments: { text: string, style: string } where style is the +// inline style corresponding to the SGR state that applies to that text. +// If the chunk contains no control codes, exactly one segment is returned using +// the current ansiState. Control codes are not included in returned text. +function processAnsiToSegments(s) { + const ESC = '\x1b'; + const csiRegex = new RegExp(ESC + '\\[[0-9;]*m', 'g'); + let match; + let lastIndex = 0; + const segments = []; + // Helper to snapshot current state and produce style + function pushSegmentIfText(text) { + if (text == null || text.length === 0) return; + // Snapshot current ansiState + const snap = { + fg: ansiState.fg, + bg: ansiState.bg, + bold: ansiState.bold, + underline: ansiState.underline, + inverse: ansiState.inverse + }; + const style = ansiStateToStyle(snap); + segments.push({ text, style }); + } + while ((match = csiRegex.exec(s)) !== null) { + const idx = match.index; + // text before the control sequence keeps the current style + if (idx > lastIndex) pushSegmentIfText(s.slice(lastIndex, idx)); + const seq = match[0]; + try { + const params = parseSgrParams(seq); + applySgr(params); + } catch (e) {} + lastIndex = csiRegex.lastIndex; + } + if (lastIndex < s.length) pushSegmentIfText(s.slice(lastIndex)); + // If there were no control codes and we produced no segments (empty input), still + // ensure we return a single empty segment? It's better to return empty array so caller can skip. + return segments; +} + +// Convert current ansiState to inline style map +function ansiStateToStyle(state) { + const styles = []; + 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('; '); +} + +// NOTE: main margin adjustments are removed; layout is flex-based and will reflow automatically. +let ws = null; +// Static graph definition (IOperationInfo) +let operations = new Map(); // name -> IOperationInfo +// Dynamic execution state (IOperationExecutionState) keyed by name +let executionStates = new Map(); +// Queued states for next iteration (IOperationExecutionState) keyed by name +let queuedStates = new Map(); +let selection = new Set(); +let sortKey = 'status'; +let sortDir = 1; // 1 asc, -1 desc +let graphSettings = null; +// Stores last execution results (previous iteration) keyed by name +let lastExecutionResults = new Map(); +const enabledStateDisplay = { + never: 'never', + 'ignore-dependency-changes': 'ignore deps', + affected: 'affected' +}; +// Table multi-select support +let tableOpOrder = []; // linear order of operations as rendered (for shift range) +let lastTableAnchorName = null; // anchor operation name +let lastTableAnchorCoord = null; // {row, phase} +let lastTablePhases = []; +let lastTablePackages = []; // array of { packageName, byPhase } +// Reconnect management +const RECONNECT_INTERVAL_MS = 4000; +let reconnectTimer = null; +let manualDisconnect = false; + +function log(msg) { + const time = new Date().toLocaleTimeString(); + // eslint-disable-next-line no-console + console.log('[' + time + '] ' + msg); +} + +function setConnected(connected) { + const connectBtnEl = document.getElementById('connect-btn'); + const iconSpan = connectBtnEl.querySelector('span.codicon'); + 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(); + } + [ + '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' + ].forEach((id) => { + const el = document.getElementById(id); + if (el) el.disabled = !connected; + }); + updateSelectionUI(); +} + +if (selectVisibleBtn) { + selectVisibleBtn.addEventListener('click', () => { + for (const op of operations.values()) { + if (!filteredOutNames.has(op.name) && !searchFilteredOutNames.has(op.name)) { + selection.add(op.name); + } + } + updateSelectionUI(); + updateGraph(); + updateTable(); + }); +} + +// Restored: central place to update UI state dependent on graphSettings +function updateManagerState() { + if (!graphSettings) return; + // Sync debug / verbose toggle button states + 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'; + } + // Update play/pause visual state + const ppIcon = playPauseBtn.querySelector('.codicon'); + 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'); + } + } + parallelismInput.value = graphSettings.parallelism; + managerStateEl.innerHTML = ''; + // Highlight execute button if an iteration is scheduled + const executeBtn = 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'); + } + } + updateStatusPill(); +} + +// Map raw status to condensed uppercase label for overall pill. +function overallStatusText(status) { + 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(); + } +} + +function updateStatusPill() { + let pillStatus = 'Disconnected'; + if (ws && ws.readyState === WebSocket.OPEN) { + pillStatus = graphSettings?.status || 'Unspecified'; + } + statusPill.className = ''; + statusPill.classList.add('status-pill', 'status-' + pillStatus); + statusEmojiEl.textContent = statusEmoji(pillStatus); + statusPill.textContent = overallStatusText(pillStatus); +} + +function computeWsUrl() { + const loc = window.location; + if (!loc || !loc.host) { + return 'ws://localhost:9001/'; // fallback when opened via file:// or unknown host + } + const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:'; + return proto + '//' + loc.host + '/ws'; +} + +function updateDerivedUrlDisplay() { + if (connectBtn) { + const url = computeWsUrl(); + connectBtn.title = 'Connect to WebSocket at ' + url; + connectBtn.setAttribute('aria-label', 'Connect to WebSocket at ' + url); + } +} + +function connect() { + if (ws && ws.readyState === WebSocket.OPEN) return; // already open + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + manualDisconnect = false; + const url = computeWsUrl(); + if (!url) return; // should not happen + // Show transient connecting state using structured spans + statusPill.className = 'status-pill status-Unspecified'; + statusEmojiEl.textContent = statusEmoji('Waiting'); + statusPill.textContent = overallStatusText('Connecting'); + log('Attempting connection to ' + url); + try { + ws = new WebSocket(url); + } catch (e) { + log('WebSocket creation failed: ' + e.message); + scheduleReconnect(); + return; + } + setConnected(false); + ws.addEventListener('open', () => { + log('Connected'); + setConnected(true); + // Immediately reflect connected pre-sync (graphSettings may not yet be populated) + statusEmojiEl.textContent = statusEmoji('Ready'); + statusPill.textContent = overallStatusText('Connected'); + }); + ws.addEventListener('close', () => { + log('Disconnected'); + setConnected(false); + updateStatusPill(); + ws = null; + if (!manualDisconnect) scheduleReconnect(); + }); + ws.addEventListener('error', (e) => { + log('Error: ' + (e.message || e.type)); + }); + ws.addEventListener('message', onMessage); +} + +function disconnect() { + manualDisconnect = true; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + if (ws) { + try { + ws.close(); + } catch {} + ws = null; + } +} + +function scheduleReconnect() { + if (reconnectTimer) return; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + if (!manualDisconnect) { + log('Reconnecting...'); + connect(); + } + }, RECONNECT_INTERVAL_MS); +} + +function sendCommand(cmd) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(cmd)); + } +} + +function onMessage(ev) { + try { + var msg = JSON.parse(ev.data); + } catch (e) { + log('Bad JSON: ' + e); + return; + } + function applyExecutionStates(stateArray) { + if (!stateArray) return; + stateArray.forEach((s) => { + executionStates.set(s.name, s); + const op = operations.get(s.name); + if (op) { + // Mirror dynamic fields that affect depiction + op.isActive = s.isActive; + op.status = s.status || op.status; + op.runInThisIteration = s.runInThisIteration; + op.logFileURLs = s.logFileURLs; + } + }); + } + switch (msg.event) { + case 'sync': { + // Static graph + operations.clear(); + msg.operations.forEach((o) => operations.set(o.name, o)); + // Dynamic states + executionStates.clear(); + applyExecutionStates(msg.currentExecutionStates); + queuedStates.clear(); + if (msg.queuedStates) msg.queuedStates.forEach((s) => queuedStates.set(s.name, s)); + graphSettings = msg.graphState; + if (msg.lastExecutionResults) { + lastExecutionResults = new Map(); + msg.lastExecutionResults.forEach((r) => lastExecutionResults.set(r.name, r)); + } + const sessionText = msg.sessionInfo.actionName + ' β€” ' + msg.sessionInfo.repositoryIdentifier; + if (appTitleEl) appTitleEl.textContent = 'rush ' + sessionText; + document.title = 'rush ' + sessionText; + markGraphDirty(); + break; + } + case 'sync-operations': { + // Replace static graph definitions; retain existing dynamic state maps + msg.operations.forEach((o) => operations.set(o.name, o)); + markGraphDirty(); + break; + } + case 'sync-graph-state': { + graphSettings = msg.graphState; + break; + } + case 'iteration-scheduled': { + queuedStates.clear(); + msg.queuedStates.forEach((s) => queuedStates.set(s.name, s)); + break; + } + case 'before-execute': { + applyExecutionStates(msg.executionStates); + // Starting iteration invalidates queued states + queuedStates.clear(); + break; + } + case 'after-execute': { + applyExecutionStates(msg.executionStates); + if (msg.lastExecutionResults) { + lastExecutionResults = new Map(); + msg.lastExecutionResults.forEach((r) => lastExecutionResults.set(r.name, r)); + } + break; + } + case 'status-change': { + applyExecutionStates(msg.executionStates); + break; + } + } + // Handle terminal-chunk events (text from stdout/stderr). These should be appended + // exactly as received; do not add extra line breaks. + if (msg.event === 'terminal-chunk') { + try { + const raw = String(msg.text || ''); + const segments = processAnsiToSegments(raw); + if (segments && segments.length) { + for (const seg of segments) { + const span = document.createElement('span'); + span.className = 'term-chunk ' + (msg.kind === 'stderr' ? 'stderr' : 'stdout'); + if (seg.style) span.setAttribute('style', seg.style); + span.textContent = seg.text; + terminalBody.appendChild(span); + } + } + // Auto-scroll if enabled + if (!termAutoScroll || termAutoScroll.checked) { + terminalBody.scrollTop = terminalBody.scrollHeight; + } + if (terminalEl && !terminalEl.classList.contains('hidden')) { + terminalEl.classList.add('term-flash'); + setTimeout(() => terminalEl.classList.remove('term-flash'), 350); + } + } catch (e) { + // ignore terminal append errors + } + } + // Ensure UI reflects any updated graph state (covers events that didn't call it earlier) + updateManagerState(); + render(); +} + +// Terminal controls +if (termClearBtn && terminalBody) { + termClearBtn.addEventListener('click', () => { + terminalBody.innerHTML = ''; + }); +} + +// Initialize terminal placement based on top bar height and main margin +(function initTerminalLayout() { + try { + const topBar = document.getElementById('top-bar'); + if (topBar && terminalEl) { + // Clear any fixed top positioning; layout is now flex-based + terminalEl.style.top = ''; + } + } catch (e) {} +})(); + +// Drag-resizer implementation +const resizer = document.getElementById('resizer'); +if (resizer && terminalEl) { + let dragging = false; + let startX = 0; + let startWidth = 0; + const minW = 120; + const maxW = Math.max(240, window.innerWidth - 200); + resizer.addEventListener('pointerdown', (e) => { + dragging = true; + startX = e.clientX; + startWidth = terminalEl.getBoundingClientRect().width; + resizer.setPointerCapture(e.pointerId); + document.body.style.userSelect = 'none'; + }); + window.addEventListener('pointermove', (e) => { + if (!dragging) return; + const dx = startX - e.clientX; // dragging left increases width + let newW = startWidth + dx; + newW = Math.max(minW, Math.min(maxW, newW)); + // Save width for unminimized use + terminalEl._savedWidth = newW; + if (!terminalEl.classList.contains('hidden')) { + terminalEl.style.width = newW + 'px'; + terminalEl.style.flex = '0 0 ' + newW + 'px'; + } + }); + window.addEventListener('pointerup', (e) => { + if (!dragging) return; + dragging = false; + try { + resizer.releasePointerCapture(e.pointerId); + } catch {} + document.body.style.userSelect = ''; + }); + // Make resizer keyboard accessible + resizer.tabIndex = 0; + resizer.addEventListener('keydown', (e) => { + const step = 16; + const rect = terminalEl.getBoundingClientRect(); + let w = rect.width; + if (e.key === 'ArrowLeft') w = Math.max(minW, w - step); + else if (e.key === 'ArrowRight') w = Math.min(maxW, w + step); + terminalEl._savedWidth = w; + if (!terminalEl.classList.contains('hidden')) { + terminalEl.style.width = w + 'px'; + terminalEl.style.flex = '0 0 ' + w + 'px'; + } + }); +} + +// Top-bar toggle: show/hide terminal. When shown the resizer is active and the +// last saved width is restored. When hidden the resizer is disabled and the +// terminal's width is remembered for the next show. +const toggleTerminalBtn = document.getElementById('toggle-terminal-btn'); +if (toggleTerminalBtn && terminalEl) { + toggleTerminalBtn.addEventListener('click', () => { + const resizerEl = document.getElementById('resizer'); + const currentlyHidden = terminalEl.classList.contains('hidden'); + if (currentlyHidden) { + // Show terminal + terminalEl.classList.remove('hidden'); + if (resizerEl) resizerEl.classList.remove('hidden'); + // Restore saved width if present + if (terminalEl._savedWidth) { + terminalEl.style.width = terminalEl._savedWidth + 'px'; + terminalEl.style.flex = '0 0 ' + terminalEl._savedWidth + 'px'; + } else { + terminalEl.style.width = ''; + terminalEl.style.flex = ''; + } + if (resizerEl) resizerEl.tabIndex = 0; + toggleTerminalBtn.setAttribute('aria-pressed', 'true'); + toggleTerminalBtn.classList.add('active'); + } else { + // Hide terminal: remember current width then hide + try { + terminalEl._savedWidth = terminalEl.getBoundingClientRect().width; + } catch (e) {} + terminalEl.classList.add('hidden'); + if (resizerEl) { + resizerEl.classList.add('hidden'); + resizerEl.tabIndex = -1; + } + // Clear inline sizing so layout reverts (we still keep _savedWidth) + terminalEl.style.width = ''; + terminalEl.style.flex = ''; + toggleTerminalBtn.setAttribute('aria-pressed', 'false'); + toggleTerminalBtn.classList.remove('active'); + } + }); + // initialize aria-pressed state + const isVisible = !terminalEl.classList.contains('hidden'); + toggleTerminalBtn.setAttribute('aria-pressed', isVisible ? 'true' : 'false'); + if (isVisible) toggleTerminalBtn.classList.add('active'); + else toggleTerminalBtn.classList.remove('active'); + // Wire terminal header controls (clear and autoscroll toggle) + const termAutoscrollBtn = document.getElementById('term-autoscroll-btn'); + const termClearBtnEl = document.getElementById('term-clear-btn'); + const termAutoScrollCheckbox = document.getElementById('term-autoscroll'); + if (termClearBtnEl && terminalBody) { + termClearBtnEl.addEventListener('click', () => { + terminalBody.innerHTML = ''; + }); + } + if (termAutoscrollBtn && termAutoScrollCheckbox) { + // Initialize aria-pressed from checkbox + termAutoscrollBtn.setAttribute('aria-pressed', termAutoScrollCheckbox.checked ? 'true' : 'false'); + termAutoscrollBtn.addEventListener('click', () => { + const newVal = !termAutoScrollCheckbox.checked; + termAutoScrollCheckbox.checked = newVal; + termAutoscrollBtn.setAttribute('aria-pressed', newVal ? 'true' : 'false'); + }); + } +} + +let currentView = 'table'; +let currentFilter = 'all'; +let searchQuery = ''; +let filteredOutNames = new Set(); // status-based (e.g. not failed/warn when filter applied) +let searchFilteredOutNames = new Set(); // search text exclusions + +// --- URL state persistence (view + filter) --------------------------- +function loadStateFromUrl() { + try { + const params = new URLSearchParams(window.location.search); + const v = params.get('view'); + if (v === 'graph' || v === 'table') currentView = v; + const f = params.get('filter'); + if (f === 'failed-warn' || f === 'all') currentFilter = f; + } catch {} +} +function syncUrlFromState() { + try { + const params = new URLSearchParams(window.location.search); + params.set('view', currentView); + params.set('filter', currentFilter); + const newUrl = window.location.pathname + '?' + params.toString() + window.location.hash; + window.history.replaceState(null, '', newUrl); + } catch {} +} +loadStateFromUrl(); + +function computeFilterSets() { + filteredOutNames = new Set(); + searchFilteredOutNames = new Set(); + const q = searchQuery.trim().toLowerCase(); + const visible = []; + for (const op of operations.values()) { + const state = executionStates.get(op.name) || {}; + // merge dynamic fields so existing UI logic can still reference op.runInThisIteration etc. + op.runInThisIteration = state.runInThisIteration; + op.status = state.status || op.status; + op.isActive = state.isActive; + op.logFileURLs = state.logFileURLs; + // Determine effective status (accounts for cache/previous run state) + const effStatus = computeDisplayStatus(op); + if (currentFilter === 'failed-warn') { + if (!(effStatus === 'Failure' || effStatus === 'SuccessWithWarning')) { + filteredOutNames.add(op.name); // graph will dim + // Exclude from table + continue; + } + } + if (q && !op.name.toLowerCase().includes(q)) { + searchFilteredOutNames.add(op.name); // graph dim variant, table hides + continue; + } + visible.push(op); + } + return visible; +} +function filteredOperationsIterable() { + const arr = computeFilterSets(); + return arr[Symbol.iterator](); +} + +// Derive array of operations used for graph layout with pruning of noop nodes. +// New behavior: skip noop nodes that are leaves (no dependents) OR that have exactly +// one incoming edge OR exactly one outgoing edge. When pruning a noop, we will +// rewire dependencies so remaining ops connect directly (i.e., bypass removed noops). +function computeGraphOperations() { + // Stable layout: always all operations; filtering only adjusts classes + const base = Array.from(operations.values()); + computeFilterSets(); + if (!base.length) return base; + // Build lookup and static dependents map (dependents = incoming adjacency) + const byName = new Map(); + base.forEach((o) => byName.set(o.name, o)); + const dependents = new Map(); // name -> Set of direct dependent op names + base.forEach((o) => { + (o.dependencies || []).forEach((d) => { + if (!byName.has(d)) return; // dependency outside filtered set not considered for pruning + (dependents.get(d) || dependents.set(d, new Set()).get(d)).add(o.name); + }); + }); + + // Active set initially contains all nodes; we'll iteratively remove noop nodes + // that meet the pruning criteria. + const active = new Set(base.map((o) => o.name)); + const noopSet = new Set(base.filter((o) => o.noop).map((o) => o.name)); + + // Compute initial counts (incoming = number of dependents, outgoing = number of deps) + const incomingCount = new Map(); + const outgoingCount = new Map(); + base.forEach((o) => { + incomingCount.set(o.name, (dependents.get(o.name) || new Set()).size); + const out = (o.dependencies || []).filter((d) => byName.has(d)).length; + outgoingCount.set(o.name, out); + }); + + // Queue noop nodes that are leaves or have exactly one incoming or one outgoing edge. + const queue = []; + active.forEach((n) => { + if (!noopSet.has(n)) return; + const inc = incomingCount.get(n) || 0; + const out = outgoingCount.get(n) || 0; + if (inc === 0 || inc === 1 || out === 1) queue.push(n); + }); + + while (queue.length) { + const name = queue.pop(); + if (!active.has(name)) continue; + // Remove this noop node + active.delete(name); + const op = byName.get(name); + if (!op) continue; + + // For each dependency of the removed node, decrement its incoming count + for (const dep of op.dependencies || []) { + if (!active.has(dep)) continue; + const prev = incomingCount.get(dep) || 0; + incomingCount.set(dep, Math.max(0, prev - 1)); + const inc = incomingCount.get(dep); + const out = outgoingCount.get(dep) || 0; + if (noopSet.has(dep) && (inc === 0 || inc === 1 || out === 1)) queue.push(dep); + } + + // For each dependent (nodes that depended on the removed node), decrement their outgoing count + const depsOf = dependents.get(name) || new Set(); + for (const dependent of depsOf) { + if (!active.has(dependent)) continue; + const prevOut = outgoingCount.get(dependent) || 0; + outgoingCount.set(dependent, Math.max(0, prevOut - 1)); + const inc = incomingCount.get(dependent) || 0; + const out = outgoingCount.get(dependent) || 0; + if (noopSet.has(dependent) && (inc === 0 || inc === 1 || out === 1)) queue.push(dependent); + } + } + + // At this point `active` contains node names to keep. We must return op objects + // whose dependency lists are rewritten so that any dependency chains that went + // through removed noop nodes are bypassed. + const removed = new Set(base.map((o) => o.name).filter((n) => !active.has(n))); + + // Memoize resolved dependencies to avoid repeated recursion. + const resolvedMemo = new Map(); + function resolveDeps(name, seen) { + if (resolvedMemo.has(name)) return resolvedMemo.get(name); + if (seen.has(name)) return new Set(); // break cycles defensively + seen.add(name); + const op = byName.get(name); + const out = new Set(); + if (!op) return out; + for (const d of op.dependencies || []) { + if (!byName.has(d)) continue; + if (active.has(d)) { + out.add(d); + } else { + // dependency was removed; splice through it + const sub = resolveDeps(d, seen); + for (const s of sub) out.add(s); + } + } + seen.delete(name); + resolvedMemo.set(name, out); + return out; + } + + const result = base + .filter((o) => active.has(o.name)) + .map((o) => { + // Compute effective dependencies for o by splicing through removed noops + const deps = new Set(); + for (const d of o.dependencies || []) { + if (!byName.has(d)) continue; + if (active.has(d)) deps.add(d); + else { + const sub = resolveDeps(d, new Set()); + for (const s of sub) deps.add(s); + } + } + // Return a shallow copy so callers can freely inspect/modify dependencies + return Object.assign({}, o, { dependencies: Array.from(deps) }); + }); + + return result; +} + +function render() { + if (currentView === 'table') { + renderTable(); + } + if (currentView === 'graph') { + ensureGraph(); + } + updateSelectionUI(); +} + +// Pivoted table: rows = packages, columns = phases +function buildPivotData() { + // Always compute the complete phase set from all known operations so columns are stable. + const allPhases = new Set(); + for (const op of operations.values()) allPhases.add(op.phaseName); + const phases = Array.from(allPhases).sort(); + // Packages only include rows that have at least one filtered-visible op OR (to keep row) we could show empty row + // We'll still build using filtered operations for cell population, but phases list remains stable. + const filteredOps = Array.from(filteredOperationsIterable()); + const map = new Map(); + for (const op of filteredOps) { + let rec = map.get(op.packageName); + if (!rec) { + rec = { packageName: op.packageName, byPhase: new Map() }; + map.set(op.packageName, rec); + } + rec.byPhase.set(op.phaseName, op); + } + const packages = Array.from(map.values()).sort((a, b) => a.packageName.localeCompare(b.packageName)); + return { phases, packages }; +} + +function renderTable() { + const { phases, packages } = buildPivotData(); + tableOpOrder = []; + lastTablePhases = phases; + lastTablePackages = packages; + // Build header + tableHead.innerHTML = ''; + const headerRow = document.createElement('tr'); + const pkgTh = document.createElement('th'); + pkgTh.textContent = 'Package'; + headerRow.appendChild(pkgTh); + for (const phase of phases) { + const th = document.createElement('th'); + const displayPhase = phase.replace(/^_phase:/, ''); + th.textContent = displayPhase; + if (displayPhase !== phase) th.title = phase; // keep original as tooltip for clarity + th.className = 'phase-col-header'; + th.style.cursor = 'pointer'; + th.addEventListener('click', (e) => { + const phaseNames = []; + for (const op of operations.values()) if (op.phaseName === phase) phaseNames.push(op.name); + handleMultiSelectGroup(e, phaseNames); + }); + headerRow.appendChild(th); + } + tableHead.appendChild(headerRow); + // Body + tableBody.innerHTML = ''; + let opCount = 0; + packages.forEach((pkg, rowIndex) => { + const tr = document.createElement('tr'); + tr.className = 'pkg-row'; + const namesInRow = Array.from(pkg.byPhase.values()).map((o) => o.name); + const allSelected = namesInRow.length && namesInRow.every((n) => selection.has(n)); + if (allSelected) tr.classList.add('selected'); + const pkgTd = document.createElement('td'); + pkgTd.className = 'pkg-cell'; + pkgTd.textContent = pkg.packageName; + pkgTd.style.fontWeight = '600'; + pkgTd.style.cursor = 'pointer'; + pkgTd.addEventListener('click', (e) => { + handleMultiSelectGroup(e, namesInRow); + e.stopPropagation(); + }); + tr.appendChild(pkgTd); + phases.forEach((phase, phaseIndex) => { + const td = document.createElement('td'); + td.className = 'pivot-cell'; + td.style.whiteSpace = 'nowrap'; + const op = pkg.byPhase.get(phase); + if (op) { + opCount++; + const displayStatus = computeDisplayStatus(op); + const glyph = enabledGlyph(op); + const active = op.isActive ? '⚑' : ''; + td.innerHTML = ` + ${statusEmoji(displayStatus)} + ${escapeHtml(overallStatusText(displayStatus))} + ${glyph} + ${active} + `; + td.title = buildTooltip(op, displayStatus); + if (selection.has(op.name)) td.classList.add('selected'); + td.style.cursor = 'pointer'; + tableOpOrder.push(op.name); + td.addEventListener('click', (e) => { + handlePivotCellClick(e, op.name, rowIndex, phaseIndex); + e.stopPropagation(); + }); + } else { + td.innerHTML = 'β€”'; + } + tr.appendChild(td); + }); + tableBody.appendChild(tr); + }); + tableStats.textContent = opCount + ' operations'; +} + +function renderFlags(op) { + const parts = []; + if (op.silent) parts.push('S'); + if (op.isActive) parts.push('A'); + if (op.enabled === 'never') parts.push('D'); + if (op.enabled === 'ignore-dependency-changes') + parts.push('I'); + return parts.join(''); +} + +function handleMultiSelectSingle(e, name) { + const isMeta = e.metaKey || e.ctrlKey; + const isShift = e.shiftKey; + const anchorExists = lastTableAnchorName && tableOpOrder.includes(lastTableAnchorName); + if (isShift && anchorExists) { + // Range selection. If meta also held, additive. + const anchorIdx = tableOpOrder.indexOf(lastTableAnchorName); + const idx = tableOpOrder.indexOf(name); + if (idx !== -1) { + const [s, eIdx] = anchorIdx < idx ? [anchorIdx, idx] : [idx, anchorIdx]; + const range = tableOpOrder.slice(s, eIdx + 1); + if (e.metaKey || e.ctrlKey) { + range.forEach((n) => selection.add(n)); + } else { + selection = new Set(range); + } + selectionChanged(); + renderTable(); + if (currentView === 'graph') ensureGraph(); + return; + } + } + if (isMeta) { + if (selection.has(name)) selection.delete(name); + else selection.add(name); + } else { + selection = new Set([name]); + } + lastTableAnchorName = name; + selectionChanged(); + renderTable(); + if (currentView === 'graph') ensureGraph(); +} + +function handleMultiSelectGroup(e, names) { + const isMeta = e.metaKey || e.ctrlKey; + const isShift = e.shiftKey; + // For a group, anchor becomes first element if not already set. + if (isShift && lastTableAnchorName && tableOpOrder.includes(lastTableAnchorName)) { + // Expand existing anchor to each item in names (additive if meta) + if (!isMeta) selection = new Set(selection); // ensure Set reference independence + names.forEach((n) => selection.add(n)); + } else if (isMeta) { + // Toggle membership of the whole group + let anyNew = false; + names.forEach((n) => { + if (!selection.delete(n)) { + selection.add(n); + anyNew = true; + } + }); + if (anyNew && names.length) lastTableAnchorName = names[0]; + } else { + selection = new Set(names); + if (names.length) lastTableAnchorName = names[0]; + } + selectionChanged(); + renderTable(); + if (currentView === 'graph') ensureGraph(); +} + +function handlePivotCellClick(e, opName, rowIndex, phaseIndex) { + if (!opName) return; + const isMeta = e.metaKey || e.ctrlKey; + const isShift = e.shiftKey; + // If shift and we have an anchor coordinate, select rectangular region. + if (isShift && lastTableAnchorCoord) { + const { row: aRow, phase: aPhase } = lastTableAnchorCoord; + const r1 = Math.min(aRow, rowIndex); + const r2 = Math.max(aRow, rowIndex); + const p1 = Math.min(aPhase, phaseIndex); + const p2 = Math.max(aPhase, phaseIndex); + const rectNames = new Set(); + for (let r = r1; r <= r2; r++) { + const pkgRec = lastTablePackages[r]; + if (!pkgRec) continue; + for (let p = p1; p <= p2; p++) { + const phase = lastTablePhases[p]; + const cellOp = pkgRec.byPhase.get(phase); + if (cellOp) rectNames.add(cellOp.name); + } + } + if (isMeta) { + // additive + rectNames.forEach((n) => selection.add(n)); + } else { + selection = rectNames; + } + selectionChanged(); + renderTable(); + if (currentView === 'graph') ensureGraph(); + return; + } + if (isMeta) { + if (selection.has(opName)) selection.delete(opName); + else selection.add(opName); + } else { + selection = new Set([opName]); + } + lastTableAnchorName = opName; + lastTableAnchorCoord = { row: rowIndex, phase: phaseIndex }; + selectionChanged(); + renderTable(); + if (currentView === 'graph') ensureGraph(); +} + +function compare(a, b) { + if (sortKey === 'status') { + // Reuse phaseStatusPriorityIndex so ordering matches phase pane & legend + const ap = phaseStatusPriorityIndex.get(a.status) ?? 999; + const bp = phaseStatusPriorityIndex.get(b.status) ?? 999; + if (ap !== bp) return ap < bp ? -1 * sortDir : 1 * sortDir; + // Fallback alphabetical on status then name for stability + const aStatus = (a.status || '').toLowerCase(); + const bStatus = (b.status || '').toLowerCase(); + if (aStatus < bStatus) return -1 * sortDir; + if (aStatus > bStatus) return 1 * sortDir; + const aName = (a.name || '').toLowerCase(); + const bName = (b.name || '').toLowerCase(); + if (aName < bName) return -1 * sortDir; + if (aName > bName) return 1 * sortDir; + return 0; + } + let av = a[sortKey]; + let bv = b[sortKey]; + if (av === undefined) av = ''; + if (bv === undefined) bv = ''; + if (typeof av === 'string') av = av.toLowerCase(); + if (typeof bv === 'string') bv = bv.toLowerCase(); + if (av < bv) return -1 * sortDir; + if (av > bv) return 1 * sortDir; + return 0; +} + +function escapeHtml(s) { + return String(s).replace( + /[&<>"']/g, + (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] + ); +} + +function singleSelect(name) { + selection.clear(); + selection.add(name); + selectionChanged(); + if (currentView === 'graph' && !graphNeedsFullRender) updateGraph(); + else render(); +} +function toggleSelect(name) { + if (selection.has(name)) selection.delete(name); + else selection.add(name); + selectionChanged(); + if (currentView === 'graph' && !graphNeedsFullRender) updateGraph(); + else render(); +} +function multiSelectRange(targetTr) { + const trs = Array.from(tableBody.querySelectorAll('tr')); + const last = selection.size + ? trs.findIndex((tr) => tr.dataset.name === Array.from(selection).slice(-1)[0]) + : -1; + const idx = trs.indexOf(targetTr); + if (last === -1) { + selection.add(targetTr.dataset.name); + selectionChanged(); + render(); + return; + } + const [s, e] = last < idx ? [last, idx] : [idx, last]; + for (let i = s; i <= e; i++) selection.add(trs[i].dataset.name); + selectionChanged(); + if (currentView === 'graph' && !graphNeedsFullRender) updateGraph(); + else render(); +} + +// Graph rendering +function computeLevels(filteredOps) { + // longest path distance from any root (no deps referencing it). We'll build reverse adjacency. + const indegree = new Map(); + const deps = new Map(); + filteredOps.forEach((op) => { + deps.set(op.name, op.dependencies || []); + indegree.set(op.name, op.dependencies.length); + }); + // nodes with indegree 0 -> level 0 (bottom) + const queue = []; + indegree.forEach((v, k) => { + if (v === 0) queue.push(k); + }); + const level = new Map(); + queue.forEach((k) => level.set(k, 0)); + while (queue.length) { + const cur = queue.shift(); + const curLevel = level.get(cur) || 0; + // find dependents (reverse edges) + filteredOps.forEach((op) => { + if (op.dependencies.includes(cur)) { + indegree.set(op.name, indegree.get(op.name) - 1); + if (!level.has(op.name) || level.get(op.name) < curLevel + 1) level.set(op.name, curLevel + 1); + if (indegree.get(op.name) === 0) queue.push(op.name); + } + }); + } + return level; // bottom-up levels +} + +// Incremental graph rendering support ------------------------------------ +function getStatusColors() { + 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 = getStatusColors(); +// Recompute on theme change (if any custom property mutations occur) +const mo = new MutationObserver(() => { + statusColors = getStatusColors(); +}); +mo.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] }); +// Simple color dimming: blend toward backdrop (#1e293b) and optionally reduce saturation +function dimColor(hex, amount = 0.55) { + // Expect #rrggbb + 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); + // Backdrop (tailwind slate-800-ish) + const br = 30, + bg = 41, + bb = 59; // #1e293b + 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') + ); +} +// Compact graph layout constants +const GRAPH_NODE_WIDTH = 28; +const GRAPH_NODE_HEIGHT = 28; +const GRAPH_COL_WIDTH = 46; // horizontal step ( >= node width ) +const GRAPH_NODE_GAP = 10; // extra gap when multiple per level +const GRAPH_LEVEL_GAP = 70; // vertical distance between levels +const GRAPH_BASE_X = 16; +const GRAPH_BASE_Y = 16; +let graphNeedsFullRender = true; // set true on sync/filter changes +const graphState = { + nodePositions: new Map(), // name -> { x,y } + nodeStatus: new Map(), // name -> last displayStatus used for coloring + nodeElements: new Map(), // name -> div element + edgeElements: [] // array of { path, from, to } +}; + +function markGraphDirty() { + graphNeedsFullRender = true; + if (currentView === 'graph') ensureGraph(); +} + +function ensureGraph() { + if (graphNeedsFullRender) { + buildGraph(); + graphNeedsFullRender = false; + } else { + updateGraph(); + } +} + +function buildGraph() { + // Clear existing + graphState.nodePositions.clear(); + graphState.nodeStatus.clear(); + graphState.nodeElements.forEach((el) => el.remove()); + graphState.nodeElements.clear(); + graphState.edgeElements.forEach((e) => e.path.remove()); + graphState.edgeElements.length = 0; + // Remove any existing marquee from previous renders + if (graphMarqueeEl) { + graphMarqueeEl.remove(); + graphMarqueeEl = null; + } + // Recreate markers + edgesSvg.innerHTML = + '' + + Object.entries(statusColors) + .map( + ([s, c]) => + `` + ) + .join('') + + ''; + const filteredOpsArr = computeGraphOperations(); + const level = computeLevels(filteredOpsArr); + const groups = {}; + level.forEach((l, name) => (groups[l] || (groups[l] = [])).push(name)); + const sortedLevels = Object.keys(groups) + .map(Number) + .sort((a, b) => a - b); + const levelGap = GRAPH_LEVEL_GAP; + const nodeGap = GRAPH_NODE_GAP; + const baseY = GRAPH_BASE_Y; + const baseX = GRAPH_BASE_X; + const colWidth = GRAPH_COL_WIDTH; + const maxLevel = sortedLevels.length ? Math.max(...sortedLevels) : 0; + // Precompute metrics for ordering within each level + // 1. Critical path length (max distance to any sink / number of edges in the longest path downstream). + // 2. Number of consumers (direct dependents) within the filtered/pruned graph. + const dependentsMap = new Map(); // name -> Set of direct dependents + filteredOpsArr.forEach((op) => { + (op.dependencies || []).forEach((d) => { + if (!operations.has(d)) return; + (dependentsMap.get(d) || dependentsMap.set(d, new Set()).get(d)).add(op.name); + }); + }); + // Build adjacency (forward edges) for DFS of downstream paths within filtered set only + const byNameFiltered = new Map(filteredOpsArr.map((o) => [o.name, o])); + const memoCpl = new Map(); + function criticalPathLen(name) { + if (memoCpl.has(name)) return memoCpl.get(name); + const op = byNameFiltered.get(name); + if (!op) { + memoCpl.set(name, 0); + return 0; + } + const deps = Array.from(dependentsMap.get(name) || []); + if (!deps.length) { + memoCpl.set(name, 0); + return 0; + } + let best = 0; + for (const d of deps) { + best = Math.max(best, 1 + criticalPathLen(d)); + } + memoCpl.set(name, best); + return best; + } + sortedLevels.forEach((l) => { + const nodes = groups[l]; + // Sort using metrics: critical path length DESC, consumers count DESC, name ASC + nodes.sort((a, b) => { + 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 = maxLevel - l; + nodes.forEach((name, j) => { + const op = operations.get(name); + if (!op) return; + const x = baseX + j * (colWidth + nodeGap); // left-aligned, no centering offset + const y = baseY + levelIndexFromTop * levelGap; + 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 = statusEmoji(computeDisplayStatus(op)); + div.appendChild(emojiSpan); + // Enabled-state superscript (will be populated in updateGraph) + const enabledSup = document.createElement('span'); + enabledSup.className = 'enabled-indicator'; + enabledSup.textContent = ''; // set later + div.appendChild(enabledSup); + div.addEventListener('click', (e) => { + e.stopPropagation(); + if (e.metaKey || e.ctrlKey) toggleSelect(name); + else singleSelect(name); + }); + graphEl.appendChild(div); + graphState.nodePositions.set(name, { x, y }); + graphState.nodeElements.set(name, div); + }); + }); + // Build edges with redundant edge pruning. + // Heuristic transitive reduction: For edge A->C, if there exists a dependency B where A->B and B can reach C, omit A->C. + const byName = new Map(filteredOpsArr.map((o) => [o.name, o])); + const memoReach = new Map(); // name -> Set of reachable deps (transitive) + function getReachable(name) { + if (memoReach.has(name)) return memoReach.get(name); + const op = byName.get(name); + const visited = new Set(); + if (op) { + const stack = [...(op.dependencies || [])]; + while (stack.length) { + const d = stack.pop(); + if (visited.has(d)) continue; + visited.add(d); + const dop = byName.get(d); + if (dop) stack.push(...(dop.dependencies || [])); + } + } + memoReach.set(name, visited); + return visited; + } + const edgeRecords = []; + for (const op of filteredOpsArr) { + const deps = op.dependencies || []; + for (const depName of deps) { + if (!byName.has(depName)) continue; + // Check redundancy: if any sibling dependency path makes this direct edge unnecessary + let redundant = false; + for (const intermediate of deps) { + if (intermediate === depName) continue; + if (!byName.has(intermediate)) continue; + const reach = getReachable(intermediate); + if (reach.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 }); + edgesSvg.appendChild(path); + } + } + graphState.edgeElements = edgeRecords; + // After structural build, do first status+style update + updateGraph(); + // Resize svg + 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)) + + levelGap + + GRAPH_NODE_HEIGHT; + edgesSvg.setAttribute('width', maxX); + edgesSvg.setAttribute('height', maxY); + } +} + +function computeDisplayStatus(op) { + // prefer dynamic execution state status if available + const state = executionStates.get(op.name); + let displayStatus = (state && state.status) || op.status; + const runInThisIteration = state ? state.runInThisIteration : op.runInThisIteration; + if (runInThisIteration === false) { + const prev = lastExecutionResults.get(op.name); + if (prev && prev.status) displayStatus = prev.status; + else displayStatus = 'Skipped'; + } + // If operation has never executed (no current state and no previous result), mark as Unknown + if (!displayStatus) { + const last = lastExecutionResults.get(op.name); + if (!last || !last.status) { + displayStatus = 'Unknown'; + } + } + return displayStatus; +} + +function enabledGlyph(op) { + if (op.noop) return 'βšͺ'; + switch (op.enabled) { + case 'never': + return 'πŸ”΄'; + case 'ignore-dependency-changes': + return '🟑'; + case 'affected': + default: + return '🟒'; + } +} + +function buildRunPolicyText(op) { + if (op.noop) return 'Operation does no work'; + switch (op.enabled) { + case 'never': + return 'Never run'; + case 'ignore-dependency-changes': + return 'Ignores dependency changes'; // legacy + new unified + case 'affected': + default: + return 'Run if affected'; + } +} + +function buildTooltip(op, lastResultStatus) { + const policy = buildRunPolicyText(op); + const activeLine = op.isActive ? '\nHas in-memory state' : ''; + return `${op.name}\nLast Result: ${lastResultStatus}\n${policy}${activeLine}`; +} + +function updateGraph() { + // Update nodes (emoji + border color only) + for (const [name, div] of graphState.nodeElements.entries()) { + const op = operations.get(name); + if (!op) continue; + const displayStatus = computeDisplayStatus(op); + const prevStatus = graphState.nodeStatus.get(name); + const state = executionStates.get(name); + const runInThisIteration = state ? state.runInThisIteration : op.runInThisIteration; + const notRunning = runInThisIteration === false || op.noop; + const queuedState = queuedStates.get(name); + const isQueuedNext = !!(queuedState && queuedState.runInThisIteration === true); + const isFilteredOut = filteredOutNames.has(name); + const isSearchFiltered = searchFilteredOutNames.has(name); + const emojiSpan = div.querySelector('.emoji'); + if (emojiSpan && (prevStatus !== displayStatus || !emojiSpan.textContent)) { + emojiSpan.textContent = statusEmoji(displayStatus); + } + // Enabled-state superscript indicator logic + const enabledSpan = div.querySelector('.enabled-indicator'); + 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'; + // Apply dimming to border color for filtered states (search stronger than status filter) + if (isSearchFiltered) baseColor = dimColor(baseColor, 0.72); + else if (isFilteredOut) baseColor = dimColor(baseColor, 0.6); + else if (notRunning) baseColor = dimColor(baseColor, 0.35); // subtle dim for not-in-iteration + div.style.borderColor = baseColor; + // selection & run-state styling + if (selection.has(name)) div.classList.add('selected'); + else div.classList.remove('selected'); + // Active indicator (rocket bottom-left) + let activeSpan = div.querySelector('.active-indicator'); + 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(); + } + // Pending changes indicator (queued for iteration) shows clock top-left + let pendingSpan = div.querySelector('.pending-indicator'); + 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(); + } + // Clear style classes first (we no longer dim node background, only border & text) + div.classList.remove('not-running', 'filtered-out', 'filtered-out-search', 'dashed', 'dotted'); + // Apply filters first; search filter strongest fade, then status filter, else not-running state + if (isSearchFiltered) { + div.classList.add('filtered-out-search'); + } else if (isFilteredOut) { + div.classList.add('filtered-out'); + } else if (notRunning) { + div.classList.add('not-running'); + } + // Enabled indicator inherits node opacity; no special handling needed. + // queued-next styling removed; rely solely on clock indicator + // Tooltip for context (name + status + optional active note) + div.title = buildTooltip(op, displayStatus); + graphState.nodeStatus.set(name, displayStatus); + } + // Update edges + for (const rec of graphState.edgeElements) { + const fromPos = graphState.nodePositions.get(rec.from); + const toPos = graphState.nodePositions.get(rec.to); + if (!fromPos || !toPos) continue; + // Anchor points: bottom-center of source -> top-center of destination + const startX = fromPos.x + GRAPH_NODE_WIDTH / 2; + const startY = fromPos.y + GRAPH_NODE_HEIGHT; // bottom of source + const endX = toPos.x + GRAPH_NODE_WIDTH / 2; + const endY = toPos.y; // top of destination + + // Compute how many rows apart they are using the level gap constant + const rowsApart = Math.max(1, Math.round((toPos.y - fromPos.y) / GRAPH_LEVEL_GAP)); + + // Horizontal column step between node centers + const colStep = GRAPH_COL_WIDTH + GRAPH_NODE_GAP; + + // Helper that emits a quadratic curve path segment from (sx,sy) to (ex,ey) + // with control points offset vertically from endpoints by 1/4 of vertical distance + function quadratic(sx, sy, ex, ey) { + const mx = (sx + ex) / 2; + const my = (sy + ey) / 2; + // Vertical offset magnitude for endpoint control points + const baseOffset = (ey - sy) / 4; + return `Q ${sx} ${sy + baseOffset} ${mx} ${my} ${ex} ${my + baseOffset} ${ex} ${ey}`; + } + + let d = ''; + if (startX === endX && rowsApart === 1) { + // Direct vertical line from bottom of source to top of destination + d = `M ${startX} ${startY} L ${endX} ${endY}`; + } else if (rowsApart === 1) { + // Exactly one row apart but different x: curve to exact midpoint then to dest + // We'll go from bottom of source -> midpoint -> top of dest using two cubic beziers + d = `M ${startX} ${startY} ` + quadratic(startX, startY, endX, endY); + } else { + // More than one row apart: build S-curve to an intermediate x (half-column shifted), + // vertical travel, then S-curve into destination. + const dir = Math.sign(endX - startX) || 1; // default to positive X if aligned + const halfColShift = colStep / 2; + // Prefer roughly halfway horizontally between source and destination + const candidateX = startX + (endX - startX) * 0.5; + // Determine number of column steps between the two nodes + const deltaCols = Math.max(1, Math.round(Math.abs(endX - startX) / colStep)); + // If the candidate lands too close to any column center (i.e. inside a node), + // nudge it by half a column into the gap between columns in the direction of travel. + 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; + + // Y where we first arrive after the S-curve from the source: + // set to the top Y coordinate of the row immediately below the source. + // This equals origin row top (fromPos.y) + GRAPH_LEVEL_GAP. + const firstTargetY = fromPos.y + GRAPH_LEVEL_GAP; + + // Y at which we stop vertical travel: bottom of the row directly above destination + const bottomOfRowAboveDest = toPos.y - GRAPH_LEVEL_GAP + GRAPH_NODE_HEIGHT; + + // Ensure we don't compute inverted vertical ranges + const midY1 = firstTargetY; + const midY2 = bottomOfRowAboveDest; + + // Build pieces: + // 1) S-curve from start -> (intermediateX, midY1) + d = `M ${startX} ${startY} ` + quadratic(startX, startY, intermediateX, midY1); + // 2) Vertical line from midY1 -> midY2 at intermediateX + d += ` L ${intermediateX} ${midY2}`; + // 3) S-curve from (intermediateX, midY2) -> destination top + 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 = operations.get(rec.from); + if (selection.has(rec.to) && selection.has(rec.from)) rec.path.classList.add('highlight'); + else rec.path.classList.remove('highlight'); + // Apply style classes + rec.path.classList.remove('dashed', 'dotted', 'filtered-out', 'not-running'); + rec.path.classList.remove('filtered-out-search'); + const fromState = fromOp ? executionStates.get(rec.from) : undefined; + const fromRunInThisIteration = fromState ? fromState.runInThisIteration : fromOp?.runInThisIteration; + const fromNotRunning = fromOp && (fromRunInThisIteration === false || fromOp.noop); + const edgeStatusFiltered = filteredOutNames.has(rec.from) || filteredOutNames.has(rec.to); + const edgeSearchFiltered = searchFilteredOutNames.has(rec.from) || searchFilteredOutNames.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 { + // Clear any explicit opacity set by filtering rules + rec.path.style.opacity = ''; + // Dim edges that are not semantically important (not executing or failed) + const semImportant = depStatus === 'Executing' || depStatus === 'Failure'; + if (!semImportant && !rec.path.classList.contains('highlight')) { + rec.path.classList.add('dim'); + } else { + rec.path.classList.remove('dim'); + } + } + } + // Update phase pane after node/edge status refresh + renderPhasePane(); + renderLegend(); + updateSelectionUI(); +} + +// Selection bar management +function updateSelectionUI() { + const bar = document.getElementById('selection-bar'); + if (!bar) return; + const hasSel = selection.size > 0; + // Always visible: heading shows context; selection-only buttons hidden if none + bar.style.display = 'flex'; + const headingSpan = document.getElementById('view-heading-text'); + if (headingSpan) { + headingSpan.textContent = currentView === 'graph' ? 'Dependency Graph' : 'Operations Table'; + } + const selectionButtonsIds = [ + 'invalidate-btn', + 'close-runners-btn', + 'set-enabled-default-btn', + 'set-enabled-ignore-deps-btn', + 'set-enabled-disabled-btn', + 'expand-deps-btn', + 'expand-consumers-btn' + ]; + selectionButtonsIds.forEach((id) => { + const el = document.getElementById(id); + if (el) { + // Enable only if we have selection AND we are connected (ws open) + const connected = ws && ws.readyState === WebSocket.OPEN; + el.disabled = !(hasSel && connected); + } + }); + const clearBtn = document.getElementById('clear-selection-btn'); + if (clearBtn) { + const connected = ws && ws.readyState === WebSocket.OPEN; + clearBtn.disabled = !(hasSel && connected); + } + const countSpan = document.getElementById('selection-count'); + if (countSpan) { + const n = selection.size; + countSpan.textContent = n + (n === 1 ? ' selected' : ' selected'); + } + if (clearBtn) { + clearBtn.title = 'Clear selection'; + clearBtn.setAttribute('aria-label', 'Clear selection'); + } +} + +function selectionChanged() { + // Immediate visibility update for selection bar to reduce perceived lag. + updateSelectionUI(); +} + +function expandSelectionDependencies() { + if (!selection.size) return; + const queue = [...selection]; + const seen = new Set(selection); + while (queue.length) { + const name = queue.shift(); + const op = 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); + } + } + } + const changed = seen.size !== selection.size; + selection = seen; + if (changed) { + selectionChanged(); + if (currentView === 'graph') ensureGraph(); + render(); + } +} + +function expandSelectionConsumers() { + if (!selection.size) return; + // Build reverse adjacency once + const dependents = new Map(); + for (const op of operations.values()) { + for (const dep of op.dependencies || []) { + if (!operations.has(dep)) continue; + (dependents.get(dep) || dependents.set(dep, new Set()).get(dep)).add(op.name); + } + } + const queue = [...selection]; + const seen = new Set(selection); + while (queue.length) { + const name = queue.shift(); + const deps = dependents.get(name); + if (!deps) continue; + for (const consumer of deps) { + if (!seen.has(consumer)) { + seen.add(consumer); + queue.push(consumer); + } + } + } + const changed = seen.size !== selection.size; + selection = seen; + if (changed) { + selectionChanged(); + if (currentView === 'graph') ensureGraph(); + render(); + } +} + +// --- Drag (marquee) selection for graph view ------------------------- +let graphMarqueeEl = null; +let dragSelecting = false; +let dragStart = null; // {x,y} in graphEl coords +let dragLast = null; // last mouse move pos +let preDragSelection = null; // Set of names existing before drag + +function graphPointFromEvent(e) { + const rect = graphEl.getBoundingClientRect(); + return { x: e.clientX - rect.left + graphEl.scrollLeft, y: e.clientY - rect.top + graphEl.scrollTop }; +} + +function beginDragSelection(e) { + if (currentView !== 'graph') return; + // Only left button + if (e.button !== 0) return; + // Avoid starting when clicking directly on a node (node click logic will handle single selection) + if (e.target && e.target.closest && e.target.closest('.op-node')) return; + dragSelecting = true; + dragStart = graphPointFromEvent(e); + dragLast = dragStart; + preDragSelection = new Set(selection); + graphMarqueeEl = document.createElement('div'); + graphMarqueeEl.className = 'graph-marquee'; + graphEl.appendChild(graphMarqueeEl); + updateMarquee(); + e.preventDefault(); +} + +function updateMarquee() { + if (!dragSelecting || !graphMarqueeEl || !dragStart || !dragLast) return; + const x1 = Math.min(dragStart.x, dragLast.x); + const y1 = Math.min(dragStart.y, dragLast.y); + const x2 = Math.max(dragStart.x, dragLast.x); + const y2 = 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'; + // Hit test nodes + const newlySelected = new Set(); + for (const [name, pos] of graphState.nodePositions.entries()) { + // Node bounding box + const nx1 = pos.x; + const ny1 = pos.y; + const nx2 = pos.x + GRAPH_NODE_WIDTH; + const ny2 = pos.y + GRAPH_NODE_HEIGHT; + if (nx2 < x1 || nx1 > x2 || ny2 < y1 || ny1 > y2) continue; // no intersect + newlySelected.add(name); + } + // Determine selection behavior + // Default: replace with marquee set; If shift/meta/ctrl: add; If alt: subtract + let finalSelection; + if (dragModifierMode === 'add') { + finalSelection = new Set(preDragSelection); + newlySelected.forEach((n) => finalSelection.add(n)); + } else if (dragModifierMode === 'subtract') { + finalSelection = new Set(preDragSelection); + newlySelected.forEach((n) => finalSelection.delete(n)); + } else { + finalSelection = newlySelected; + } + selection = finalSelection; + updateGraph(); // incremental refresh without full rebuild +} + +let dragModifierMode = 'replace'; // 'replace' | 'add' | 'subtract' + +function updateDragModifierMode(e) { + if (e.altKey) dragModifierMode = 'subtract'; + else if (e.metaKey || e.ctrlKey || e.shiftKey) dragModifierMode = 'add'; + else dragModifierMode = 'replace'; +} + +graphEl.addEventListener('mousedown', (e) => { + updateDragModifierMode(e); + beginDragSelection(e); +}); +graphEl.addEventListener('mousemove', (e) => { + if (!dragSelecting) return; + dragLast = graphPointFromEvent(e); + updateDragModifierMode(e); + updateMarquee(); + e.preventDefault(); +}); +window.addEventListener('mouseup', (e) => { + if (!dragSelecting) return; + dragSelecting = false; + if (graphMarqueeEl) { + graphMarqueeEl.remove(); + graphMarqueeEl = null; + } + dragStart = null; + dragLast = null; + preDragSelection = null; +}); +// Keep modifier mode in sync if user changes keys mid-drag outside graph area +window.addEventListener('keydown', (e) => { + if (dragSelecting) { + updateDragModifierMode(e); + updateMarquee(); + } +}); +window.addEventListener('keyup', (e) => { + if (dragSelecting) { + updateDragModifierMode(e); + updateMarquee(); + } +}); + +// Phase summary pane ---------------------------------------------------- +const phaseStatusPriority = [ + 'Failure', + 'SuccessWithWarning', + 'Blocked', + 'Aborted', + 'Executing', + 'Queued', + 'Ready', + 'Waiting', + 'Success', + 'Skipped', + 'FromCache', + 'NoOp' +]; +const phaseStatusPriorityIndex = new Map(phaseStatusPriority.map((s, i) => [s, i])); + +function computePhaseSummaries() { + const byPhase = new Map(); // phase -> { ops: [], statusSet: Set } + const graphMembership = + graphState && graphState.nodePositions.size ? new Set(graphState.nodePositions.keys()) : null; + for (const op of operations.values()) { + const phase = op.phaseName || '(none)'; + const displayStatus = computeDisplayStatus(op); + // Always include Executing operations so they are visible even if filtered out of the graph (e.g. failed-warn filter) + 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 = []; + for (const [phase, rec] of byPhase.entries()) { + // Determine most significant status by lowest index in priority list + let chosen = null; + let bestIdx = Infinity; + for (const s of rec.statusSet) { + const idx = phaseStatusPriorityIndex.get(s); + if (idx !== undefined && idx < bestIdx) { + bestIdx = idx; + chosen = s; + } + } + if (!chosen) { + chosen = 'Ready'; + } + // Problem operations list includes failures, warnings, and currently executing operations + 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; +} + +function renderPhasePane() { + if (!phaseGroupsEl) return; + const summaries = computePhaseSummaries(); + phaseGroupsEl.innerHTML = ''; + if (!summaries.length) { + const empty = document.createElement('div'); + empty.className = 'phase-pane-empty'; + empty.textContent = 'No phases'; + phaseGroupsEl.appendChild(empty); + return; + } + for (const s of summaries) { + const div = document.createElement('div'); + div.className = 'phase-group'; + const header = document.createElement('div'); + header.className = 'phase-header'; + const emoji = document.createElement('span'); + emoji.className = 'phase-status-emoji'; + emoji.textContent = statusEmoji(s.status); + const nameSpan = document.createElement('span'); + nameSpan.className = 'phase-name'; + // Only strip the exact leading `_phase:` prefix if present + nameSpan.textContent = s.phase.replace(/^_phase:/, ''); + header.appendChild(emoji); + header.appendChild(nameSpan); + div.appendChild(header); + if (s.problemOps.length) { + const ul = document.createElement('ul'); + ul.className = 'phase-problems'; + // Sort by status priority, then by operation name for stability + const sortedProblems = [...s.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 li = document.createElement('li'); + const emo = document.createElement('span'); + emo.className = 'phase-problem-emoji'; + emo.textContent = statusEmoji(displayStatus); + li.appendChild(emo); + const logUrl = + (op.logFileURLs && (op.logFileURLs.text || op.logFileURLs.error || op.logFileURLs.jsonl)) || null; + if (logUrl) { + const a = document.createElement('a'); + a.href = logUrl; + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + a.textContent = op.name; + li.appendChild(a); + } else { + const span = document.createElement('span'); + span.textContent = op.name; + li.appendChild(span); + } + ul.appendChild(li); + } + div.appendChild(ul); + } + phaseGroupsEl.appendChild(div); + } +} + +// Legend --------------------------------------------------------------- +// Reuse the same priority ordering as phase summaries for legend ordering +const legendOrder = [...phaseStatusPriority]; +function renderLegend() { + if (!legendEl) return; + if (!legendEl._initialized) { + const header = document.createElement('h4'); + header.textContent = 'Legend'; + // Collapse toggle button + 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; + // Restore collapsed state + const collapsed = 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'); + // Re-render to hide/show content + renderLegend(); + }); + } + while (legendEl.children.length > 1) legendEl.removeChild(legendEl.lastChild); + const collapsedNow = legendEl.classList.contains('collapsed'); + if (collapsedNow) { + // Minimal stub when collapsed + const stub = document.createElement('div'); + stub.style.fontSize = '0.5rem'; + stub.style.opacity = '0.7'; + stub.textContent = 'Collapsed'; + legendEl.appendChild(stub); + return; + } + const columnsWrap = document.createElement('div'); + columnsWrap.className = 'legend-columns'; + // Primary column: core execution statuses + 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 = 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 = overallStatusText(status); + labelWrap.appendChild(titleSpan); + row.appendChild(sample); + row.appendChild(labelWrap); + colPrimary.appendChild(row); + } + // Unknown status (never executed) legend 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); + // Secondary column: run/enable/active/filter semantics + 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); + function addModifier(sampleFactory, label, detail) { + 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); + } + // Helper factories + const makeNodeBox = (borderStyle, boxShadow, borderColor) => { + const span = document.createElement('span'); + span.className = 'legend-emoji'; + if (borderStyle) span.style.borderStyle = borderStyle; + if (borderColor) span.style.borderColor = borderColor; + if (boxShadow) span.style.boxShadow = boxShadow; + span.textContent = ' '; // visual box only + return span; + }; + const makeQueued = () => makeNodeBox(null, '0 0 0 2px var(--warn) inset', 'var(--warn)'); + const makeDashed = () => { + const s = makeNodeBox('dashed'); + return s; + }; + const makeDotted = () => { + const s = makeNodeBox('dotted'); + return s; + }; + const makeActive = () => { + 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 = () => { + 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) => { + 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'); + // Enabled state grouping heading + 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); +} + +const statusEmojiMap = { + Ready: '⏸️', + Waiting: 'πŸ•˜', + Queued: 'πŸ“', + Executing: 'βš™οΈ', + Success: 'βœ…', + SuccessWithWarning: '⚠️', + Skipped: 'πŸ’€', + FromCache: '🟩', + Failure: '❌', + Blocked: '🚫', + NoOp: 'πŸ’€', + Aborted: 'πŸ›‘', + Disconnected: '⏸️', + Unknown: '❓' +}; +function statusEmoji(status) { + return statusEmojiMap[status] || 'β€’'; +} + +// Table sorting +// (Removed old sortable column logic; pivoted view handles selection differently.) + +// Action buttons +document.getElementById('connect-btn').addEventListener('click', () => { + if (ws && ws.readyState === WebSocket.OPEN) { + disconnect(); + } else { + connect(); + } +}); +document + .getElementById('invalidate-btn') + .addEventListener('click', () => + sendCommand({ command: 'invalidate', operationNames: Array.from(selection) }) + ); +document + .getElementById('close-runners-btn') + .addEventListener('click', () => + sendCommand({ command: 'close-runners', operationNames: Array.from(selection) }) + ); +const expandDepsBtn = document.getElementById('expand-deps-btn'); +if (expandDepsBtn) expandDepsBtn.addEventListener('click', expandSelectionDependencies); +const expandConsumersBtn = document.getElementById('expand-consumers-btn'); +if (expandConsumersBtn) expandConsumersBtn.addEventListener('click', expandSelectionConsumers); +const setEnabledDefaultBtn = document.getElementById('set-enabled-default-btn'); +const setEnabledIgnoreDepsBtn = document.getElementById('set-enabled-ignore-deps-btn'); +const setEnabledDisabledBtn = document.getElementById('set-enabled-disabled-btn'); +const selectionModeBtn = document.getElementById('selection-mode-btn'); +let selectionEnableMode = 'safe'; // 'safe' | 'unsafe' +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.'; + }); +} +function sendEnableState(targetState) { + 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')); +document.getElementById('execute-btn').addEventListener('click', () => sendCommand({ command: 'execute' })); +document + .getElementById('abort-execution-btn') + .addEventListener('click', () => sendCommand({ command: 'abort-execution' })); +document.getElementById('clear-selection-btn').addEventListener('click', () => { + if (selection.size) { + selection.clear(); + render(); + } +}); +debugBtn.addEventListener('click', () => { + const newVal = !graphSettings?.debugMode; + // Optimistic UI update + debugBtn.title = newVal ? 'Turn off debug logging' : 'Turn on debug logging'; + sendCommand({ command: 'set-debug', value: newVal }); +}); +verboseBtn.addEventListener('click', () => { + const newVal = !graphSettings?.verbose; + verboseBtn.title = newVal ? 'Turn off verbose logging' : 'Turn on verbose logging'; + sendCommand({ command: 'set-verbose', value: newVal }); +}); +parallelismInput.addEventListener('change', () => + sendCommand({ command: 'set-parallelism', parallelism: Number(parallelismInput.value) || 1 }) +); +playPauseBtn.addEventListener('click', () => { + if (!graphSettings) return; + const next = !!graphSettings.pauseNextIteration; // current boolean + // Toggle: if currently paused (true), set to false (resume automatic); otherwise set to true (pause) + sendCommand({ command: 'set-pause-next-iteration', value: !next }); +}); +window.addEventListener('keydown', (e) => { + if (e.key === 'a' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + selection = new Set(Array.from(operations.keys())); + render(); + } + if (e.key === 'Escape') { + if (selection.size) { + selection.clear(); + render(); + } + } +}); +// (Dynamic legend offset removed; static CSS handles scrollbar clearance.) + +// objFromSelection removed (replaced by sendEnableState helper for new API) + +// expose for debug +window.__rushServeDemo = { operations, selection }; +updateDerivedUrlDisplay(); + +// View toggle & filter handlers +document.querySelectorAll('input[name="view"]').forEach((r) => { + r.addEventListener('change', () => { + if (r.checked) { + currentView = r.value; + document.getElementById('left').style.display = currentView === 'table' ? '' : 'none'; + document.getElementById('right').style.display = currentView === 'graph' ? '' : 'none'; + syncUrlFromState(); + render(); + } + }); +}); +document.getElementById('filter-select').addEventListener('change', (e) => { + currentFilter = e.target.value; + // Filter change requires full rebuild of graph + markGraphDirty(); + syncUrlFromState(); + render(); +}); +const nameSearchInput = document.getElementById('name-search'); +nameSearchInput.addEventListener('input', () => { + searchQuery = nameSearchInput.value; + // Table re-render always; graph full rebuild (layout may shrink) when search changes. + markGraphDirty(); + render(); +}); +// Initialize visibility states +// Apply loaded state to controls (radio + filter) +const viewRadio = document.querySelector(`input[name="view"][value="${currentView}"]`); +if (viewRadio) viewRadio.checked = true; +const filterSelect = document.getElementById('filter-select'); +if (filterSelect) filterSelect.value = currentFilter; +document.getElementById('left').style.display = currentView === 'table' ? '' : 'none'; +document.getElementById('right').style.display = currentView === 'graph' ? '' : 'none'; +syncUrlFromState(); +// Auto-connect on load +updateDerivedUrlDisplay(); +connect(); From ed9ef40214660268c70423d5abcf8c604617a47c Mon Sep 17 00:00:00 2001 From: Mo Jazayeri Date: Fri, 24 Jul 2026 18:40:14 +0000 Subject: [PATCH 02/12] Split dashboard layout to more granular modules --- .../rush-plugins/rush-serve-plugin.json | 2 +- common/config/rush/command-line.json | 2 + rush-plugins/rush-serve-plugin/README.md | 6 +- .../src/dashboard/dashboard.css | 1268 -------- .../src/dashboard/dashboard.html | 7 +- .../src/dashboard/dashboard.ts | 2736 +++-------------- .../src/dashboard/modules/ansiSgrParser.ts | 136 + .../dashboard/modules/dashboardMutations.ts | 53 + .../dashboard/modules/dashboardWebSocket.ts | 128 + .../src/dashboard/modules/graphFiltering.ts | 177 ++ .../src/dashboard/modules/graphSelection.ts | 246 ++ .../src/dashboard/modules/graphView.ts | 553 ++++ .../src/dashboard/modules/leftBar.ts | 93 + .../src/dashboard/modules/mainBar.ts | 107 + .../src/dashboard/modules/phaseLegend.ts | 446 +++ .../src/dashboard/modules/selectionBar.ts | 67 + .../src/dashboard/modules/statusHelpers.ts | 109 + .../src/dashboard/modules/tableView.ts | 270 ++ .../src/dashboard/modules/terminalPane.ts | 206 ++ .../src/dashboard/modules/topBar.ts | 188 ++ .../src/dashboard/modules/urlState.ts | 47 + .../src/dashboard/modules/viewBar.ts | 84 + .../src/dashboard/styles/dashboard.css | 553 ++++ .../src/dashboard/styles/graph-view.css | 449 +++ .../src/dashboard/styles/selection-bar.css | 56 + .../src/dashboard/styles/table-view.css | 108 + .../src/dashboard/styles/terminal-pane.css | 113 + .../src/dashboard/styles/top-bar.css | 112 + .../src/phasedCommandHandler.ts | 1 + rush-plugins/rush-serve-plugin/tsconfig.json | 1 + 30 files changed, 4743 insertions(+), 3581 deletions(-) delete mode 100644 rush-plugins/rush-serve-plugin/src/dashboard/dashboard.css create mode 100644 rush-plugins/rush-serve-plugin/src/dashboard/modules/ansiSgrParser.ts create mode 100644 rush-plugins/rush-serve-plugin/src/dashboard/modules/dashboardMutations.ts create mode 100644 rush-plugins/rush-serve-plugin/src/dashboard/modules/dashboardWebSocket.ts create mode 100644 rush-plugins/rush-serve-plugin/src/dashboard/modules/graphFiltering.ts create mode 100644 rush-plugins/rush-serve-plugin/src/dashboard/modules/graphSelection.ts create mode 100644 rush-plugins/rush-serve-plugin/src/dashboard/modules/graphView.ts create mode 100644 rush-plugins/rush-serve-plugin/src/dashboard/modules/leftBar.ts create mode 100644 rush-plugins/rush-serve-plugin/src/dashboard/modules/mainBar.ts create mode 100644 rush-plugins/rush-serve-plugin/src/dashboard/modules/phaseLegend.ts create mode 100644 rush-plugins/rush-serve-plugin/src/dashboard/modules/selectionBar.ts create mode 100644 rush-plugins/rush-serve-plugin/src/dashboard/modules/statusHelpers.ts create mode 100644 rush-plugins/rush-serve-plugin/src/dashboard/modules/tableView.ts create mode 100644 rush-plugins/rush-serve-plugin/src/dashboard/modules/terminalPane.ts create mode 100644 rush-plugins/rush-serve-plugin/src/dashboard/modules/topBar.ts create mode 100644 rush-plugins/rush-serve-plugin/src/dashboard/modules/urlState.ts create mode 100644 rush-plugins/rush-serve-plugin/src/dashboard/modules/viewBar.ts create mode 100644 rush-plugins/rush-serve-plugin/src/dashboard/styles/dashboard.css create mode 100644 rush-plugins/rush-serve-plugin/src/dashboard/styles/graph-view.css create mode 100644 rush-plugins/rush-serve-plugin/src/dashboard/styles/selection-bar.css create mode 100644 rush-plugins/rush-serve-plugin/src/dashboard/styles/table-view.css create mode 100644 rush-plugins/rush-serve-plugin/src/dashboard/styles/terminal-pane.css create mode 100644 rush-plugins/rush-serve-plugin/src/dashboard/styles/top-bar.css diff --git a/common/config/rush-plugins/rush-serve-plugin.json b/common/config/rush-plugins/rush-serve-plugin.json index e2a1eacd618..d42c234aeb5 100644 --- a/common/config/rush-plugins/rush-serve-plugin.json +++ b/common/config/rush-plugins/rush-serve-plugin.json @@ -6,7 +6,7 @@ "logServePath": "/logs", "globalRouting": [ { - "workspaceRelativeFolder": "rush-plugins/rush-serve-plugin/lib-commonjs/dashboard", + "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 c51c5789378..0087a181d3f 100644 --- a/rush-plugins/rush-serve-plugin/README.md +++ b/rush-plugins/rush-serve-plugin/README.md @@ -22,7 +22,7 @@ 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 built assets are emitted to `lib-commonjs/dashboard/`. Because the HTML references sibling `dashboard.css` and `dashboard.ts` files, serve the built folder rather than a single file: +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 { @@ -31,7 +31,7 @@ This package includes a ready-made dashboard whose source lives under `src/dashb "buildStatusWebSocketPath": "/ws", "globalRouting": [ { - "workspaceRelativeFolder": "rush-plugins/rush-serve-plugin/lib-commonjs/dashboard", + "workspaceRelativeFolder": "rush-plugins/rush-serve-plugin/lib-esm/dashboard", "servePath": "/dashboard", "immutable": false } @@ -39,6 +39,8 @@ This package includes a ready-made dashboard whose source lives under `src/dashb } ``` +The Rush server resolves extensionless module requests to emitted `.js` files. + Then open `https://localhost:/dashboard/dashboard.html`. To use the socket: diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/dashboard.css b/rush-plugins/rush-serve-plugin/src/dashboard/dashboard.css deleted file mode 100644 index 01ab1bd4b93..00000000000 --- a/rush-plugins/rush-serve-plugin/src/dashboard/dashboard.css +++ /dev/null @@ -1,1268 +0,0 @@ -/* 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 triplets for alpha blending (previously omitted) */ - --rgb-accent: 59, 130, 246; /* matches --accent */ - --rgb-accent-hover: 37, 99, 235; /* matches --accent-hover */ - --rgb-success: 16, 185, 129; /* matches --success */ - --rgb-warn: 245, 158, 11; /* matches --warn */ - --rgb-danger: 239, 68, 68; /* matches --danger */ - --rgb-white: 255, 255, 255; /* white for subtle overlays */ - --rgb-panel-grad-a: 30, 37, 48; /* #1e2530 */ - --rgb-panel-grad-b: 16, 19, 24; /* #101318 */ - /* Status semantic hues */ - --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; - /* Graph / scrollbar backgrounds */ - --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; -} -#top-bar { - display: flex; - flex-wrap: wrap; - gap: 0.5rem 0.75rem; - align-items: baseline; /* baseline alignment for tighter visual rhythm */ - padding: 0.5rem 0.75rem; - background: var(--panel); - border-bottom: 1px solid var(--border); - max-height: 40vh; - overflow-y: auto; -} -/* Ensure all immediate children share a consistent baseline context */ -#top-bar > * { - display: flex; - align-items: baseline; -} -/* Top bar specific baseline alignment adjustments */ -#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; /* nudge glyphs down to sit on baseline visually */ - line-height: 1; -} -/* Connected (disconnect) state = red foreground */ -#connection-form #connect-btn[data-state='connected'] { - color: var(--danger); -} -/* Disconnected state = accent (blue) foreground */ -#connection-form #connect-btn[data-state='disconnected'] { - color: var(--accent); -} -/* Align abort-session emoji exactly with disconnect icon */ -/* Dark mode spin buttons for number input */ -/* Remove default light-mode spinner arrows and supply subtle 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; -} -/* Custom spinner using background gradients (up/down triangles) */ -#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 !important; /* space for custom arrows */ -} -#top-bar input[type='number'] { - appearance: textfield; - -moz-appearance: textfield; /* Firefox remove default */ -} -#top-bar input[type='number']:focus { - outline: 1px solid var(--accent); - outline-offset: 0; -} -/* Let large groups (actions, graph-state, view-controls) keep their internal layouts */ -#actions, -#graph-state, -#view-controls { - align-items: baseline; -} -/* Normalize status pill and emoji to align with surrounding text */ -#overall-status { - align-items: baseline; -} -#status-emoji { - position: relative; - top: 1px; -} -#status-pill { - line-height: 1.05; -} -.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; -} -#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; -} -.content-wrap { - display: flex; - flex: 1; - min-height: 0; -} -#main { - flex: 1; - display: flex; - min-height: 0; -} -#resizer { - width: 6px; - cursor: col-resize; - background: transparent; -} -#term-autoscroll { - display: none; -} -#overall-status { - display: flex; - align-items: center; -} -#status-emoji { - font-size: 1rem; - line-height: 1; - display: inline-flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; -} -#status-pill { - padding: 2px 10px 2px 10px; - border-radius: 999px; - font-size: 0.7rem; - font-weight: 600; - line-height: 1; -} -#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; -} -/* Unified select styling */ -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); -} -/* 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 { - align-items: center; -} -#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; -} -/* Base action buttons inside selection bar (leave icon buttons separate) */ -#connection-form button.action, -.icon-btn { - padding: 0.4rem 0.75rem; - border-radius: var(--radius); - #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-bar[aria-hidden='true'] { - display: none; - } - #selection-actions button.action { - font-size: 0.65rem; - } - font-weight: 600; - cursor: pointer; -} -/* Remove previous special backgrounds for connection buttons; icon-btn handles visuals */ -#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; - /* Default state = manual (showing play icon) -> green */ - 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); -} -/* Automatic mode (showing pause icon) -> blue accent */ -.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; -} -/* Toggle buttons */ -.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; - } -} -/* Stylable icons (avoid emoji rendering differences) */ -/* Codicon adjustments */ -.codicon { - font-size: 18px; - line-height: 1; -} -.icon-btn .codicon { - pointer-events: none; -} -.icon-btn.stop .codicon { - font-size: 17px; -} -/* Slight size boost when queued */ -.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); -} -#main { - flex: 1; - display: flex; - min-height: 0; -} -#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; -} -#actions { - display: flex; - flex-wrap: wrap; - gap: 0.4rem; -} -#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-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); -} -/* Newly introduced Unknown status for operations that have never executed */ -.status-Unknown { - background: var(--status-ready); - opacity: 0.85; -} -/* Disconnected (top-level pill only) should share a neutral background */ -.status-Disconnected { - background: var(--status-disconnected); -} -.mono { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; -} -#graph-container { - position: relative; - flex: 1; - min-height: 0; -} -#graph { - width: 100%; - height: 100%; - position: relative; - overflow: auto; - background: radial-gradient(circle at 25% 20%, #1e2530, #101318); -} -/* Phase summary pane + graph layout */ -#graph-container { - display: flex; - flex-direction: row; - align-items: stretch; - gap: 0; -} -#phase-pane { - width: 220px; - background: var(--panel); - border-right: 1px solid var(--border); - /* Prevent overall pane scrollbar; internal lists handle scrolling */ - 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 { - /* With flex distribution each phase takes equal vertical space */ - 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; /* allow shrinking */ -} -/* Container holding phase groups: turn into flex column with uniform distribution */ -#phase-pane .phase-groups { - display: flex; - flex-direction: column; - gap: 0.6rem; - height: calc(100% - 1.2rem); /* subtract header + padding roughly */ - overflow: hidden; /* groups themselves don't scroll */ -} -#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; /* interactive now (collapse button) */ -} -#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); /* updated dynamically */ - border-radius: 6px; - background: #1f2530; - box-sizing: border-box; -} -/* Legend sample showing subscript placement of enabled state glyph */ -.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; /* base (not used directly now) */ - 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; -} -/* Drag selection marquee */ -.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); /* color updated dynamically per status */ - border-radius: 6px; - background: #1f2530; - box-sizing: border-box; - user-select: none; - cursor: pointer; - transition: - border-color 0.15s ease, - transform 0.05s ease; -} -/* Superscript enabled-state indicator (traffic light) */ -.op-node .enabled-indicator { - position: absolute; - bottom: 0; - right: 0; - transform: translate(50%, 50%); /* center over corner */ - 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; -} -/* Emoji inherits dimming via node classes */ -.op-node.selected { - box-shadow: 0 0 0 2px var(--accent); -} -.op-node:hover { - transform: scale(1.08); -} -/* Active indicator: rocket emoji bottom-left */ -.op-node .active-indicator { - position: absolute; - bottom: 0; - left: 0; - transform: translate(-50%, 50%); /* center over corner */ - font-size: 12px; - line-height: 1; - pointer-events: none; - filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.55)); -} -/* Pending changes indicator: clock emoji top-left */ -.op-node .pending-indicator { - position: absolute; - top: 0; - left: 0; - transform: translate(-50%, -50%); /* center over corner */ - font-size: 12px; - line-height: 1; - pointer-events: none; - filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.55)); -} -/* Never dim active indicator; ensure full opacity */ -.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; -} -/* Queued for next iteration indicator (pulse removed) */ -/* queued-next styling removed (no visual border effect retained) */ -.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; -} -/* Enabled indicator should follow same dimming semantics as main emoji */ -.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; -} -.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; -} -.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; -} -/* Node emoji fade (background always opaque). We'll inject a span.emoji inside each node. */ -.op-node .emoji { - transition: opacity 0.15s ease; -} -.op-node.filtered-out .emoji { - opacity: 0.25; -} -/* Search filtering: a bit stronger fade so user can distinguish from status filter */ -.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 and marker assigned dynamically per status; removing defaults eliminates shadow/misaligned base color */ - 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; -} -/* Dim edges that are not executing or failed to de-emphasize passive links */ -.edge.dim { - opacity: 0.28; - filter: brightness(0.8); -} -.edge.not-running { - opacity: 0.55; -} -.pivot-enabled { - margin-left: 4px; - font-size: 0.75rem; -} -.pivot-active { - margin-left: 4px; - font-size: 0.75rem; - position: relative; - top: 1px; -} -#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; -} -#graph-state { - display: flex; - gap: 0.75rem; - flex-wrap: wrap; -} -.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; -} -#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); -} -@media (max-width: 1200px) { - #main { - flex-direction: column; - } - #left { - border-right: none; - border-bottom: 1px solid var(--border); - } -} -/* Dark scrollbars (WebKit) */ -* { - scrollbar-color: var(--scroll-thumb) var(--scroll-track); /* Firefox */ - 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); -} -/* Terminal pane */ -/* Side terminal pane (right side). We'll position fixed and adjust main margin when open. */ -.terminal-container { - background: #000; /* default terminal background: black */ - border-left: 1px solid var(--border); - color: var(--text); - font-size: 0.75rem; - display: flex; - flex-direction: column; - align-self: stretch; - /* Use a fixed flex-basis so layout respects terminal width */ - 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; -} -/* The terminal pane is always fully expanded when visible; minimized/collapsed - behavior has been removed in favor of top-bar show/hide. */ -/* Content wrap allows browser to layout main + resizer + terminal naturally */ -.content-wrap { - display: flex; - flex: 1 1 0; - min-height: 0; - overflow: hidden; -} -#resizer { - 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); -} -/* When terminal is controlled via top-bar toggle, the header minimize button is not used. */ -/* Hidden state: remove terminal and resizer from layout */ -.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; /* preserve exact whitespace and line breaks from source; do NOT auto-wrap */ - 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; -} -/* Active icon buttons (e.g. verbose, terminal) use accent color */ -.icon-btn.active, -.icon-btn.toggle.active { - color: var(--accent); -} -/* Ensure the top-bar terminal icon specifically uses accent when active (higher specificity) */ -#toggle-terminal-btn.active, -#toggle-terminal-btn.active .codicon { - color: var(--accent) !important; - fill: var(--accent) !important; -} -/* Rotate an icon to indicate vertical scrolling */ -.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/dashboard.html b/rush-plugins/rush-serve-plugin/src/dashboard/dashboard.html index 1c824970d88..f7776bf5238 100644 --- a/rush-plugins/rush-serve-plugin/src/dashboard/dashboard.html +++ b/rush-plugins/rush-serve-plugin/src/dashboard/dashboard.html @@ -13,7 +13,12 @@ href="https://microsoft.github.io/vscode-codicons/dist/codicon.css" crossorigin="anonymous" /> - + + + + + +
diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/dashboard.ts b/rush-plugins/rush-serve-plugin/src/dashboard/dashboard.ts index e85094a4679..a9db5d82e24 100644 --- a/rush-plugins/rush-serve-plugin/src/dashboard/dashboard.ts +++ b/rush-plugins/rush-serve-plugin/src/dashboard/dashboard.ts @@ -1,2399 +1,517 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -// @ts-nocheck - -// Removed in-page log panel; logging now goes directly to devtools console -const statusPill = document.getElementById('status-pill'); -const statusEmojiEl = document.getElementById('status-emoji'); -const connectBtn = document.getElementById('connect-btn'); -const appTitleEl = document.getElementById('app-title'); -const tableEl = document.getElementById('operations-table'); +/* 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'); -const managerStateEl = document.getElementById('graph-state'); -const edgesSvg = document.getElementById('edges'); -const graphEl = document.getElementById('graph'); -const legendEl = document.getElementById('graph-legend'); -const phasePaneEl = document.getElementById('phase-pane'); +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'); -const parallelismInput = document.getElementById('parallelism-input'); -const debugBtn = document.getElementById('debug-btn'); -const verboseBtn = document.getElementById('verbose-btn'); -const selectVisibleBtn = document.getElementById('select-visible-btn'); -// Terminal elements -const terminalEl = document.getElementById('terminal'); -const terminalBody = document.getElementById('terminal-body'); -const termClearBtn = document.getElementById('term-clear-btn'); -const termAutoScroll = document.getElementById('term-autoscroll'); -// Terminal styling state parsed from VT SGR sequences. We'll maintain a stack-like currentState object -const ansiState = { - bold: false, - underline: false, - inverse: false, - fg: null, // CSS color string or null - bg: 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 }; -// Basic SGR parser: handles ESC [ ... m sequences. Returns array of SGR numeric params. -function parseSgrParams(seq) { - // seq expected like '\u001b[31;1m' or '31;1' - const s = seq.replace(/^\x1b\[|m$/g, ''); - if (!s) return [0]; - return s.split(';').map((p) => Number(p || 0)); -} +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' +]; -// Map common SGR codes to actions that mutate ansiState. We only support common color codes and attributes. -function applySgr(params) { - if (!params || !params.length) params = [0]; - for (const p of params) { - if (p === 0) { - ansiState.bold = false; - ansiState.underline = false; - ansiState.inverse = false; - ansiState.fg = null; - ansiState.bg = null; - } else if (p === 1) { - ansiState.bold = true; - } else if (p === 4) { - ansiState.underline = true; - } else if (p === 7) { - ansiState.inverse = true; - } else if (p === 22) { - ansiState.bold = false; - } else if (p === 24) { - ansiState.underline = false; - } else if (p >= 30 && p <= 37) { - ansiState.fg = sgrColorToCss(p - 30, false); - } else if (p === 39) { - ansiState.fg = null; - } else if (p >= 40 && p <= 47) { - ansiState.bg = sgrColorToCss(p - 40, false); - } else if (p === 49) { - ansiState.bg = null; - } else if (p >= 90 && p <= 97) { - ansiState.fg = sgrColorToCss(p - 90, true); - } else if (p >= 100 && p <= 107) { - ansiState.bg = sgrColorToCss(p - 100, true); - } else { - // ignore other codes (including 38/48 extended color sequences for now) - } - } -} +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 +}); -function sgrColorToCss(idx, bright) { - // Map 0..7 to CSS colors roughly matching common terminal palette - const base = [ - '#000000', // black - '#a00', // red - '#0a0', // green - '#aa0', // yellow - '#00a', // blue - '#a0a', // magenta - '#0aa', // cyan - '#ddd' // white / light gray - ]; - const brightMap = ['#555', '#ff5555', '#55ff55', '#ffff55', '#5555ff', '#ff55ff', '#55ffff', '#fff']; - return bright ? brightMap[idx] || base[idx] : base[idx] || null; +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(); } -// Process ANSI control sequences (CSI sequences) and update ansiState for SGRs. -// Returns an array of segments: { text: string, style: string } where style is the -// inline style corresponding to the SGR state that applies to that text. -// If the chunk contains no control codes, exactly one segment is returned using -// the current ansiState. Control codes are not included in returned text. -function processAnsiToSegments(s) { - const ESC = '\x1b'; - const csiRegex = new RegExp(ESC + '\\[[0-9;]*m', 'g'); - let match; - let lastIndex = 0; - const segments = []; - // Helper to snapshot current state and produce style - function pushSegmentIfText(text) { - if (text == null || text.length === 0) return; - // Snapshot current ansiState - const snap = { - fg: ansiState.fg, - bg: ansiState.bg, - bold: ansiState.bold, - underline: ansiState.underline, - inverse: ansiState.inverse - }; - const style = ansiStateToStyle(snap); - segments.push({ text, style }); - } - while ((match = csiRegex.exec(s)) !== null) { - const idx = match.index; - // text before the control sequence keeps the current style - if (idx > lastIndex) pushSegmentIfText(s.slice(lastIndex, idx)); - const seq = match[0]; - try { - const params = parseSgrParams(seq); - applySgr(params); - } catch (e) {} - lastIndex = csiRegex.lastIndex; - } - if (lastIndex < s.length) pushSegmentIfText(s.slice(lastIndex)); - // If there were no control codes and we produced no segments (empty input), still - // ensure we return a single empty segment? It's better to return empty array so caller can skip. - return segments; +function singleSelect(name: string): void { + graphSelectionController.singleSelect(name); } -// Convert current ansiState to inline style map -function ansiStateToStyle(state) { - const styles = []; - 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('; '); +function toggleSelect(name: string): void { + graphSelectionController.toggleSelect(name); } -// NOTE: main margin adjustments are removed; layout is flex-based and will reflow automatically. -let ws = null; -// Static graph definition (IOperationInfo) -let operations = new Map(); // name -> IOperationInfo -// Dynamic execution state (IOperationExecutionState) keyed by name -let executionStates = new Map(); -// Queued states for next iteration (IOperationExecutionState) keyed by name -let queuedStates = new Map(); -let selection = new Set(); -let sortKey = 'status'; -let sortDir = 1; // 1 asc, -1 desc -let graphSettings = null; -// Stores last execution results (previous iteration) keyed by name -let lastExecutionResults = new Map(); -const enabledStateDisplay = { - never: 'never', - 'ignore-dependency-changes': 'ignore deps', - affected: 'affected' -}; -// Table multi-select support -let tableOpOrder = []; // linear order of operations as rendered (for shift range) -let lastTableAnchorName = null; // anchor operation name -let lastTableAnchorCoord = null; // {row, phase} -let lastTablePhases = []; -let lastTablePackages = []; // array of { packageName, byPhase } -// Reconnect management -const RECONNECT_INTERVAL_MS = 4000; -let reconnectTimer = null; -let manualDisconnect = false; +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 +}); -function log(msg) { - const time = new Date().toLocaleTimeString(); - // eslint-disable-next-line no-console - console.log('[' + time + '] ' + msg); -} +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 +}); -function setConnected(connected) { - const connectBtnEl = document.getElementById('connect-btn'); - const iconSpan = connectBtnEl.querySelector('span.codicon'); - 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(); +graphSelectionController.wireGraphMarqueeSelection(); + +function markGraphDirty(): void { + graphViewController.markGraphDirty(); + if (currentView === 'graph') { + ensureGraph(); } - [ - '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' - ].forEach((id) => { - const el = document.getElementById(id); - if (el) el.disabled = !connected; - }); - updateSelectionUI(); } -if (selectVisibleBtn) { - selectVisibleBtn.addEventListener('click', () => { - for (const op of operations.values()) { - if (!filteredOutNames.has(op.name) && !searchFilteredOutNames.has(op.name)) { - selection.add(op.name); - } - } - updateSelectionUI(); - updateGraph(); - updateTable(); - }); +function ensureGraph(): void { + graphViewController.ensureGraph(); } -// Restored: central place to update UI state dependent on graphSettings -function updateManagerState() { - if (!graphSettings) return; - // Sync debug / verbose toggle button states - 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'; - } - // Update play/pause visual state - const ppIcon = playPauseBtn.querySelector('.codicon'); - 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'); - } +function updateGraph(): void { + graphViewController.updateGraph(); +} + +function renderTable(): void { + tableViewController.renderTable(); +} + +function render(): void { + if (currentView === 'table') { + renderTable(); } 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'); - } - } - parallelismInput.value = graphSettings.parallelism; - managerStateEl.innerHTML = ''; - // Highlight execute button if an iteration is scheduled - const executeBtn = 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'); - } + ensureGraph(); } - updateStatusPill(); + updateSelectionUI(); } -// Map raw status to condensed uppercase label for overall pill. -function overallStatusText(status) { - 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(); - } +function setConnected(connected: boolean): void { + setTopBarConnected(topBarRefs, connected, updateSelectionUI, disabledControlIds); } -function updateStatusPill() { - let pillStatus = 'Disconnected'; - if (ws && ws.readyState === WebSocket.OPEN) { - pillStatus = graphSettings?.status || 'Unspecified'; - } - statusPill.className = ''; - statusPill.classList.add('status-pill', 'status-' + pillStatus); - statusEmojiEl.textContent = statusEmoji(pillStatus); - statusPill.textContent = overallStatusText(pillStatus); +function updateDerivedUrlDisplay(): void { + updateTopBarDerivedUrlDisplay(connectBtn); } -function computeWsUrl() { - const loc = window.location; - if (!loc || !loc.host) { - return 'ws://localhost:9001/'; // fallback when opened via file:// or unknown host - } - const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:'; - return proto + '//' + loc.host + '/ws'; +function updateStatusPill(): void { + updateTopBarStatusPill(topBarRefs, socketController.getSocket(), graphSettings, statusEmoji); } -function updateDerivedUrlDisplay() { - if (connectBtn) { - const url = computeWsUrl(); - connectBtn.title = 'Connect to WebSocket at ' + url; - connectBtn.setAttribute('aria-label', 'Connect to WebSocket at ' + url); - } +function updateManagerState(): void { + if (!graphSettings) return; + updateTopBarManagerState(topBarRefs, graphSettings); } -function connect() { - if (ws && ws.readyState === WebSocket.OPEN) return; // already open - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - manualDisconnect = false; - const url = computeWsUrl(); - if (!url) return; // should not happen - // Show transient connecting state using structured spans - statusPill.className = 'status-pill status-Unspecified'; - statusEmojiEl.textContent = statusEmoji('Waiting'); - statusPill.textContent = overallStatusText('Connecting'); - log('Attempting connection to ' + url); - try { - ws = new WebSocket(url); - } catch (e) { - log('WebSocket creation failed: ' + e.message); - scheduleReconnect(); - return; - } - setConnected(false); - ws.addEventListener('open', () => { - log('Connected'); - setConnected(true); - // Immediately reflect connected pre-sync (graphSettings may not yet be populated) - statusEmojiEl.textContent = statusEmoji('Ready'); - statusPill.textContent = overallStatusText('Connected'); - }); - ws.addEventListener('close', () => { - log('Disconnected'); - setConnected(false); +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(); - ws = null; - if (!manualDisconnect) scheduleReconnect(); - }); - ws.addEventListener('error', (e) => { - log('Error: ' + (e.message || e.type)); - }); - ws.addEventListener('message', onMessage); -} + }, + onClose: () => { + updateStatusPill(); + }, + onError: (event: Event) => { + log('WebSocket error: ' + event.type); + }, + onParsedMessage: (message: unknown) => { + handleMessage(message as IDashboardMessage); + }, + onParseError: (error: unknown) => { + log('Bad JSON: ' + String(error)); + }, + onLog: log +}); -function disconnect() { - manualDisconnect = true; - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - if (ws) { - try { - ws.close(); - } catch {} - ws = null; - } +const selectionBarController = createSelectionBarController({ + getSelection: () => selection, + getCurrentView: () => currentView, + isConnected: () => socketController.isConnected() +}); + +function connect(): void { + socketController.connect(); } -function scheduleReconnect() { - if (reconnectTimer) return; - reconnectTimer = setTimeout(() => { - reconnectTimer = null; - if (!manualDisconnect) { - log('Reconnecting...'); - connect(); - } - }, RECONNECT_INTERVAL_MS); +function disconnect(): void { + socketController.disconnect(); } -function sendCommand(cmd) { - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify(cmd)); - } +function sendCommand(cmd: unknown): void { + socketController.sendCommand(cmd); } -function onMessage(ev) { - try { - var msg = JSON.parse(ev.data); - } catch (e) { - log('Bad JSON: ' + e); - return; - } - function applyExecutionStates(stateArray) { - if (!stateArray) return; - stateArray.forEach((s) => { - executionStates.set(s.name, s); - const op = operations.get(s.name); - if (op) { - // Mirror dynamic fields that affect depiction - op.isActive = s.isActive; - op.status = s.status || op.status; - op.runInThisIteration = s.runInThisIteration; - op.logFileURLs = s.logFileURLs; - } - }); - } +function handleMessage(msg: IDashboardMessage): void { switch (msg.event) { case 'sync': { - // Static graph - operations.clear(); - msg.operations.forEach((o) => operations.set(o.name, o)); - // Dynamic states + setOperationsFromPayload(operations, msg.operations || []); executionStates.clear(); - applyExecutionStates(msg.currentExecutionStates); - queuedStates.clear(); - if (msg.queuedStates) msg.queuedStates.forEach((s) => queuedStates.set(s.name, s)); - graphSettings = msg.graphState; - if (msg.lastExecutionResults) { - lastExecutionResults = new Map(); - msg.lastExecutionResults.forEach((r) => lastExecutionResults.set(r.name, r)); + 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; } - const sessionText = msg.sessionInfo.actionName + ' β€” ' + msg.sessionInfo.repositoryIdentifier; - if (appTitleEl) appTitleEl.textContent = 'rush ' + sessionText; - document.title = 'rush ' + sessionText; markGraphDirty(); break; } case 'sync-operations': { - // Replace static graph definitions; retain existing dynamic state maps - msg.operations.forEach((o) => operations.set(o.name, o)); + patchOperationsFromPayload(operations, msg.operations || []); markGraphDirty(); break; } case 'sync-graph-state': { - graphSettings = msg.graphState; + graphSettings = msg.graphState || null; break; } case 'iteration-scheduled': { - queuedStates.clear(); - msg.queuedStates.forEach((s) => queuedStates.set(s.name, s)); + setQueuedStates(queuedStates, msg.queuedStates || []); break; } - case 'before-execute': { - applyExecutionStates(msg.executionStates); - // Starting iteration invalidates queued states - queuedStates.clear(); + case 'before-execute': + case 'status-change': { + applyExecutionStatesMutation(operations, executionStates, msg.executionStates || []); break; } case 'after-execute': { - applyExecutionStates(msg.executionStates); - if (msg.lastExecutionResults) { - lastExecutionResults = new Map(); - msg.lastExecutionResults.forEach((r) => lastExecutionResults.set(r.name, r)); + applyExecutionStatesMutation(operations, executionStates, msg.executionStates || []); + lastExecutionResults = toLastExecutionResultsMap(msg.resultByOperation || []); + if (graphSettings && msg.status) { + graphSettings.status = msg.status; } break; } - case 'status-change': { - applyExecutionStates(msg.executionStates); + case 'terminal-chunk': { + terminalPane.appendChunk(msg.kind, msg.text); break; } } - // Handle terminal-chunk events (text from stdout/stderr). These should be appended - // exactly as received; do not add extra line breaks. - if (msg.event === 'terminal-chunk') { - try { - const raw = String(msg.text || ''); - const segments = processAnsiToSegments(raw); - if (segments && segments.length) { - for (const seg of segments) { - const span = document.createElement('span'); - span.className = 'term-chunk ' + (msg.kind === 'stderr' ? 'stderr' : 'stdout'); - if (seg.style) span.setAttribute('style', seg.style); - span.textContent = seg.text; - terminalBody.appendChild(span); - } - } - // Auto-scroll if enabled - if (!termAutoScroll || termAutoScroll.checked) { - terminalBody.scrollTop = terminalBody.scrollHeight; - } - if (terminalEl && !terminalEl.classList.contains('hidden')) { - terminalEl.classList.add('term-flash'); - setTimeout(() => terminalEl.classList.remove('term-flash'), 350); - } - } catch (e) { - // ignore terminal append errors - } - } - // Ensure UI reflects any updated graph state (covers events that didn't call it earlier) + updateManagerState(); + updateStatusPill(); render(); } -// Terminal controls -if (termClearBtn && terminalBody) { - termClearBtn.addEventListener('click', () => { - terminalBody.innerHTML = ''; - }); +function updateSelectionUI(): void { + selectionBarController.updateSelectionUI(); } -// Initialize terminal placement based on top bar height and main margin -(function initTerminalLayout() { - try { - const topBar = document.getElementById('top-bar'); - if (topBar && terminalEl) { - // Clear any fixed top positioning; layout is now flex-based - terminalEl.style.top = ''; - } - } catch (e) {} -})(); - -// Drag-resizer implementation -const resizer = document.getElementById('resizer'); -if (resizer && terminalEl) { - let dragging = false; - let startX = 0; - let startWidth = 0; - const minW = 120; - const maxW = Math.max(240, window.innerWidth - 200); - resizer.addEventListener('pointerdown', (e) => { - dragging = true; - startX = e.clientX; - startWidth = terminalEl.getBoundingClientRect().width; - resizer.setPointerCapture(e.pointerId); - document.body.style.userSelect = 'none'; - }); - window.addEventListener('pointermove', (e) => { - if (!dragging) return; - const dx = startX - e.clientX; // dragging left increases width - let newW = startWidth + dx; - newW = Math.max(minW, Math.min(maxW, newW)); - // Save width for unminimized use - terminalEl._savedWidth = newW; - if (!terminalEl.classList.contains('hidden')) { - terminalEl.style.width = newW + 'px'; - terminalEl.style.flex = '0 0 ' + newW + 'px'; - } - }); - window.addEventListener('pointerup', (e) => { - if (!dragging) return; - dragging = false; - try { - resizer.releasePointerCapture(e.pointerId); - } catch {} - document.body.style.userSelect = ''; - }); - // Make resizer keyboard accessible - resizer.tabIndex = 0; - resizer.addEventListener('keydown', (e) => { - const step = 16; - const rect = terminalEl.getBoundingClientRect(); - let w = rect.width; - if (e.key === 'ArrowLeft') w = Math.max(minW, w - step); - else if (e.key === 'ArrowRight') w = Math.min(maxW, w + step); - terminalEl._savedWidth = w; - if (!terminalEl.classList.contains('hidden')) { - terminalEl.style.width = w + 'px'; - terminalEl.style.flex = '0 0 ' + w + 'px'; - } - }); +function expandSelectionDependencies(): void { + graphSelectionController.expandSelectionDependencies(); } -// Top-bar toggle: show/hide terminal. When shown the resizer is active and the -// last saved width is restored. When hidden the resizer is disabled and the -// terminal's width is remembered for the next show. -const toggleTerminalBtn = document.getElementById('toggle-terminal-btn'); -if (toggleTerminalBtn && terminalEl) { - toggleTerminalBtn.addEventListener('click', () => { - const resizerEl = document.getElementById('resizer'); - const currentlyHidden = terminalEl.classList.contains('hidden'); - if (currentlyHidden) { - // Show terminal - terminalEl.classList.remove('hidden'); - if (resizerEl) resizerEl.classList.remove('hidden'); - // Restore saved width if present - if (terminalEl._savedWidth) { - terminalEl.style.width = terminalEl._savedWidth + 'px'; - terminalEl.style.flex = '0 0 ' + terminalEl._savedWidth + 'px'; - } else { - terminalEl.style.width = ''; - terminalEl.style.flex = ''; - } - if (resizerEl) resizerEl.tabIndex = 0; - toggleTerminalBtn.setAttribute('aria-pressed', 'true'); - toggleTerminalBtn.classList.add('active'); - } else { - // Hide terminal: remember current width then hide - try { - terminalEl._savedWidth = terminalEl.getBoundingClientRect().width; - } catch (e) {} - terminalEl.classList.add('hidden'); - if (resizerEl) { - resizerEl.classList.add('hidden'); - resizerEl.tabIndex = -1; - } - // Clear inline sizing so layout reverts (we still keep _savedWidth) - terminalEl.style.width = ''; - terminalEl.style.flex = ''; - toggleTerminalBtn.setAttribute('aria-pressed', 'false'); - toggleTerminalBtn.classList.remove('active'); - } - }); - // initialize aria-pressed state - const isVisible = !terminalEl.classList.contains('hidden'); - toggleTerminalBtn.setAttribute('aria-pressed', isVisible ? 'true' : 'false'); - if (isVisible) toggleTerminalBtn.classList.add('active'); - else toggleTerminalBtn.classList.remove('active'); - // Wire terminal header controls (clear and autoscroll toggle) - const termAutoscrollBtn = document.getElementById('term-autoscroll-btn'); - const termClearBtnEl = document.getElementById('term-clear-btn'); - const termAutoScrollCheckbox = document.getElementById('term-autoscroll'); - if (termClearBtnEl && terminalBody) { - termClearBtnEl.addEventListener('click', () => { - terminalBody.innerHTML = ''; - }); - } - if (termAutoscrollBtn && termAutoScrollCheckbox) { - // Initialize aria-pressed from checkbox - termAutoscrollBtn.setAttribute('aria-pressed', termAutoScrollCheckbox.checked ? 'true' : 'false'); - termAutoscrollBtn.addEventListener('click', () => { - const newVal = !termAutoScrollCheckbox.checked; - termAutoScrollCheckbox.checked = newVal; - termAutoscrollBtn.setAttribute('aria-pressed', newVal ? 'true' : 'false'); - }); - } -} - -let currentView = 'table'; -let currentFilter = 'all'; -let searchQuery = ''; -let filteredOutNames = new Set(); // status-based (e.g. not failed/warn when filter applied) -let searchFilteredOutNames = new Set(); // search text exclusions - -// --- URL state persistence (view + filter) --------------------------- -function loadStateFromUrl() { - try { - const params = new URLSearchParams(window.location.search); - const v = params.get('view'); - if (v === 'graph' || v === 'table') currentView = v; - const f = params.get('filter'); - if (f === 'failed-warn' || f === 'all') currentFilter = f; - } catch {} -} -function syncUrlFromState() { - try { - const params = new URLSearchParams(window.location.search); - params.set('view', currentView); - params.set('filter', currentFilter); - const newUrl = window.location.pathname + '?' + params.toString() + window.location.hash; - window.history.replaceState(null, '', newUrl); - } catch {} -} -loadStateFromUrl(); - -function computeFilterSets() { - filteredOutNames = new Set(); - searchFilteredOutNames = new Set(); - const q = searchQuery.trim().toLowerCase(); - const visible = []; - for (const op of operations.values()) { - const state = executionStates.get(op.name) || {}; - // merge dynamic fields so existing UI logic can still reference op.runInThisIteration etc. - op.runInThisIteration = state.runInThisIteration; - op.status = state.status || op.status; - op.isActive = state.isActive; - op.logFileURLs = state.logFileURLs; - // Determine effective status (accounts for cache/previous run state) - const effStatus = computeDisplayStatus(op); - if (currentFilter === 'failed-warn') { - if (!(effStatus === 'Failure' || effStatus === 'SuccessWithWarning')) { - filteredOutNames.add(op.name); // graph will dim - // Exclude from table - continue; - } - } - if (q && !op.name.toLowerCase().includes(q)) { - searchFilteredOutNames.add(op.name); // graph dim variant, table hides - continue; - } - visible.push(op); - } - return visible; -} -function filteredOperationsIterable() { - const arr = computeFilterSets(); - return arr[Symbol.iterator](); -} - -// Derive array of operations used for graph layout with pruning of noop nodes. -// New behavior: skip noop nodes that are leaves (no dependents) OR that have exactly -// one incoming edge OR exactly one outgoing edge. When pruning a noop, we will -// rewire dependencies so remaining ops connect directly (i.e., bypass removed noops). -function computeGraphOperations() { - // Stable layout: always all operations; filtering only adjusts classes - const base = Array.from(operations.values()); - computeFilterSets(); - if (!base.length) return base; - // Build lookup and static dependents map (dependents = incoming adjacency) - const byName = new Map(); - base.forEach((o) => byName.set(o.name, o)); - const dependents = new Map(); // name -> Set of direct dependent op names - base.forEach((o) => { - (o.dependencies || []).forEach((d) => { - if (!byName.has(d)) return; // dependency outside filtered set not considered for pruning - (dependents.get(d) || dependents.set(d, new Set()).get(d)).add(o.name); - }); - }); - - // Active set initially contains all nodes; we'll iteratively remove noop nodes - // that meet the pruning criteria. - const active = new Set(base.map((o) => o.name)); - const noopSet = new Set(base.filter((o) => o.noop).map((o) => o.name)); - - // Compute initial counts (incoming = number of dependents, outgoing = number of deps) - const incomingCount = new Map(); - const outgoingCount = new Map(); - base.forEach((o) => { - incomingCount.set(o.name, (dependents.get(o.name) || new Set()).size); - const out = (o.dependencies || []).filter((d) => byName.has(d)).length; - outgoingCount.set(o.name, out); - }); - - // Queue noop nodes that are leaves or have exactly one incoming or one outgoing edge. - const queue = []; - active.forEach((n) => { - if (!noopSet.has(n)) return; - const inc = incomingCount.get(n) || 0; - const out = outgoingCount.get(n) || 0; - if (inc === 0 || inc === 1 || out === 1) queue.push(n); - }); - - while (queue.length) { - const name = queue.pop(); - if (!active.has(name)) continue; - // Remove this noop node - active.delete(name); - const op = byName.get(name); - if (!op) continue; - - // For each dependency of the removed node, decrement its incoming count - for (const dep of op.dependencies || []) { - if (!active.has(dep)) continue; - const prev = incomingCount.get(dep) || 0; - incomingCount.set(dep, Math.max(0, prev - 1)); - const inc = incomingCount.get(dep); - const out = outgoingCount.get(dep) || 0; - if (noopSet.has(dep) && (inc === 0 || inc === 1 || out === 1)) queue.push(dep); - } - - // For each dependent (nodes that depended on the removed node), decrement their outgoing count - const depsOf = dependents.get(name) || new Set(); - for (const dependent of depsOf) { - if (!active.has(dependent)) continue; - const prevOut = outgoingCount.get(dependent) || 0; - outgoingCount.set(dependent, Math.max(0, prevOut - 1)); - const inc = incomingCount.get(dependent) || 0; - const out = outgoingCount.get(dependent) || 0; - if (noopSet.has(dependent) && (inc === 0 || inc === 1 || out === 1)) queue.push(dependent); - } - } - - // At this point `active` contains node names to keep. We must return op objects - // whose dependency lists are rewritten so that any dependency chains that went - // through removed noop nodes are bypassed. - const removed = new Set(base.map((o) => o.name).filter((n) => !active.has(n))); - - // Memoize resolved dependencies to avoid repeated recursion. - const resolvedMemo = new Map(); - function resolveDeps(name, seen) { - if (resolvedMemo.has(name)) return resolvedMemo.get(name); - if (seen.has(name)) return new Set(); // break cycles defensively - seen.add(name); - const op = byName.get(name); - const out = new Set(); - if (!op) return out; - for (const d of op.dependencies || []) { - if (!byName.has(d)) continue; - if (active.has(d)) { - out.add(d); - } else { - // dependency was removed; splice through it - const sub = resolveDeps(d, seen); - for (const s of sub) out.add(s); - } - } - seen.delete(name); - resolvedMemo.set(name, out); - return out; - } - - const result = base - .filter((o) => active.has(o.name)) - .map((o) => { - // Compute effective dependencies for o by splicing through removed noops - const deps = new Set(); - for (const d of o.dependencies || []) { - if (!byName.has(d)) continue; - if (active.has(d)) deps.add(d); - else { - const sub = resolveDeps(d, new Set()); - for (const s of sub) deps.add(s); - } - } - // Return a shallow copy so callers can freely inspect/modify dependencies - return Object.assign({}, o, { dependencies: Array.from(deps) }); - }); - - return result; +function expandSelectionConsumers(): void { + graphSelectionController.expandSelectionConsumers(); } -function render() { - if (currentView === 'table') { - renderTable(); - } - if (currentView === 'graph') { - ensureGraph(); - } - updateSelectionUI(); -} - -// Pivoted table: rows = packages, columns = phases -function buildPivotData() { - // Always compute the complete phase set from all known operations so columns are stable. - const allPhases = new Set(); - for (const op of operations.values()) allPhases.add(op.phaseName); - const phases = Array.from(allPhases).sort(); - // Packages only include rows that have at least one filtered-visible op OR (to keep row) we could show empty row - // We'll still build using filtered operations for cell population, but phases list remains stable. - const filteredOps = Array.from(filteredOperationsIterable()); - const map = new Map(); - for (const op of filteredOps) { - let rec = map.get(op.packageName); - if (!rec) { - rec = { packageName: op.packageName, byPhase: new Map() }; - map.set(op.packageName, rec); - } - rec.byPhase.set(op.phaseName, op); - } - const packages = Array.from(map.values()).sort((a, b) => a.packageName.localeCompare(b.packageName)); - return { phases, packages }; -} - -function renderTable() { - const { phases, packages } = buildPivotData(); - tableOpOrder = []; - lastTablePhases = phases; - lastTablePackages = packages; - // Build header - tableHead.innerHTML = ''; - const headerRow = document.createElement('tr'); - const pkgTh = document.createElement('th'); - pkgTh.textContent = 'Package'; - headerRow.appendChild(pkgTh); - for (const phase of phases) { - const th = document.createElement('th'); - const displayPhase = phase.replace(/^_phase:/, ''); - th.textContent = displayPhase; - if (displayPhase !== phase) th.title = phase; // keep original as tooltip for clarity - th.className = 'phase-col-header'; - th.style.cursor = 'pointer'; - th.addEventListener('click', (e) => { - const phaseNames = []; - for (const op of operations.values()) if (op.phaseName === phase) phaseNames.push(op.name); - handleMultiSelectGroup(e, phaseNames); - }); - headerRow.appendChild(th); - } - tableHead.appendChild(headerRow); - // Body - tableBody.innerHTML = ''; - let opCount = 0; - packages.forEach((pkg, rowIndex) => { - const tr = document.createElement('tr'); - tr.className = 'pkg-row'; - const namesInRow = Array.from(pkg.byPhase.values()).map((o) => o.name); - const allSelected = namesInRow.length && namesInRow.every((n) => selection.has(n)); - if (allSelected) tr.classList.add('selected'); - const pkgTd = document.createElement('td'); - pkgTd.className = 'pkg-cell'; - pkgTd.textContent = pkg.packageName; - pkgTd.style.fontWeight = '600'; - pkgTd.style.cursor = 'pointer'; - pkgTd.addEventListener('click', (e) => { - handleMultiSelectGroup(e, namesInRow); - e.stopPropagation(); - }); - tr.appendChild(pkgTd); - phases.forEach((phase, phaseIndex) => { - const td = document.createElement('td'); - td.className = 'pivot-cell'; - td.style.whiteSpace = 'nowrap'; - const op = pkg.byPhase.get(phase); - if (op) { - opCount++; - const displayStatus = computeDisplayStatus(op); - const glyph = enabledGlyph(op); - const active = op.isActive ? '⚑' : ''; - td.innerHTML = ` - ${statusEmoji(displayStatus)} - ${escapeHtml(overallStatusText(displayStatus))} - ${glyph} - ${active} - `; - td.title = buildTooltip(op, displayStatus); - if (selection.has(op.name)) td.classList.add('selected'); - td.style.cursor = 'pointer'; - tableOpOrder.push(op.name); - td.addEventListener('click', (e) => { - handlePivotCellClick(e, op.name, rowIndex, phaseIndex); - e.stopPropagation(); - }); - } else { - td.innerHTML = 'β€”'; - } - tr.appendChild(td); - }); - tableBody.appendChild(tr); - }); - tableStats.textContent = opCount + ' operations'; -} - -function renderFlags(op) { - const parts = []; - if (op.silent) parts.push('S'); - if (op.isActive) parts.push('A'); - if (op.enabled === 'never') parts.push('D'); - if (op.enabled === 'ignore-dependency-changes') - parts.push('I'); - return parts.join(''); -} - -function handleMultiSelectSingle(e, name) { - const isMeta = e.metaKey || e.ctrlKey; - const isShift = e.shiftKey; - const anchorExists = lastTableAnchorName && tableOpOrder.includes(lastTableAnchorName); - if (isShift && anchorExists) { - // Range selection. If meta also held, additive. - const anchorIdx = tableOpOrder.indexOf(lastTableAnchorName); - const idx = tableOpOrder.indexOf(name); - if (idx !== -1) { - const [s, eIdx] = anchorIdx < idx ? [anchorIdx, idx] : [idx, anchorIdx]; - const range = tableOpOrder.slice(s, eIdx + 1); - if (e.metaKey || e.ctrlKey) { - range.forEach((n) => selection.add(n)); - } else { - selection = new Set(range); - } - selectionChanged(); - renderTable(); - if (currentView === 'graph') ensureGraph(); - return; - } - } - if (isMeta) { - if (selection.has(name)) selection.delete(name); - else selection.add(name); - } else { - selection = new Set([name]); - } - lastTableAnchorName = name; - selectionChanged(); - renderTable(); - if (currentView === 'graph') ensureGraph(); -} - -function handleMultiSelectGroup(e, names) { - const isMeta = e.metaKey || e.ctrlKey; - const isShift = e.shiftKey; - // For a group, anchor becomes first element if not already set. - if (isShift && lastTableAnchorName && tableOpOrder.includes(lastTableAnchorName)) { - // Expand existing anchor to each item in names (additive if meta) - if (!isMeta) selection = new Set(selection); // ensure Set reference independence - names.forEach((n) => selection.add(n)); - } else if (isMeta) { - // Toggle membership of the whole group - let anyNew = false; - names.forEach((n) => { - if (!selection.delete(n)) { - selection.add(n); - anyNew = true; - } - }); - if (anyNew && names.length) lastTableAnchorName = names[0]; - } else { - selection = new Set(names); - if (names.length) lastTableAnchorName = names[0]; - } - selectionChanged(); - renderTable(); - if (currentView === 'graph') ensureGraph(); -} - -function handlePivotCellClick(e, opName, rowIndex, phaseIndex) { - if (!opName) return; - const isMeta = e.metaKey || e.ctrlKey; - const isShift = e.shiftKey; - // If shift and we have an anchor coordinate, select rectangular region. - if (isShift && lastTableAnchorCoord) { - const { row: aRow, phase: aPhase } = lastTableAnchorCoord; - const r1 = Math.min(aRow, rowIndex); - const r2 = Math.max(aRow, rowIndex); - const p1 = Math.min(aPhase, phaseIndex); - const p2 = Math.max(aPhase, phaseIndex); - const rectNames = new Set(); - for (let r = r1; r <= r2; r++) { - const pkgRec = lastTablePackages[r]; - if (!pkgRec) continue; - for (let p = p1; p <= p2; p++) { - const phase = lastTablePhases[p]; - const cellOp = pkgRec.byPhase.get(phase); - if (cellOp) rectNames.add(cellOp.name); - } - } - if (isMeta) { - // additive - rectNames.forEach((n) => selection.add(n)); - } else { - selection = rectNames; - } - selectionChanged(); - renderTable(); - if (currentView === 'graph') ensureGraph(); - return; - } - if (isMeta) { - if (selection.has(opName)) selection.delete(opName); - else selection.add(opName); - } else { - selection = new Set([opName]); - } - lastTableAnchorName = opName; - lastTableAnchorCoord = { row: rowIndex, phase: phaseIndex }; - selectionChanged(); - renderTable(); - if (currentView === 'graph') ensureGraph(); -} - -function compare(a, b) { - if (sortKey === 'status') { - // Reuse phaseStatusPriorityIndex so ordering matches phase pane & legend - const ap = phaseStatusPriorityIndex.get(a.status) ?? 999; - const bp = phaseStatusPriorityIndex.get(b.status) ?? 999; - if (ap !== bp) return ap < bp ? -1 * sortDir : 1 * sortDir; - // Fallback alphabetical on status then name for stability - const aStatus = (a.status || '').toLowerCase(); - const bStatus = (b.status || '').toLowerCase(); - if (aStatus < bStatus) return -1 * sortDir; - if (aStatus > bStatus) return 1 * sortDir; - const aName = (a.name || '').toLowerCase(); - const bName = (b.name || '').toLowerCase(); - if (aName < bName) return -1 * sortDir; - if (aName > bName) return 1 * sortDir; - return 0; - } - let av = a[sortKey]; - let bv = b[sortKey]; - if (av === undefined) av = ''; - if (bv === undefined) bv = ''; - if (typeof av === 'string') av = av.toLowerCase(); - if (typeof bv === 'string') bv = bv.toLowerCase(); - if (av < bv) return -1 * sortDir; - if (av > bv) return 1 * sortDir; - return 0; -} - -function escapeHtml(s) { - return String(s).replace( - /[&<>"']/g, - (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] - ); -} - -function singleSelect(name) { - selection.clear(); - selection.add(name); - selectionChanged(); - if (currentView === 'graph' && !graphNeedsFullRender) updateGraph(); - else render(); -} -function toggleSelect(name) { - if (selection.has(name)) selection.delete(name); - else selection.add(name); - selectionChanged(); - if (currentView === 'graph' && !graphNeedsFullRender) updateGraph(); - else render(); -} -function multiSelectRange(targetTr) { - const trs = Array.from(tableBody.querySelectorAll('tr')); - const last = selection.size - ? trs.findIndex((tr) => tr.dataset.name === Array.from(selection).slice(-1)[0]) - : -1; - const idx = trs.indexOf(targetTr); - if (last === -1) { - selection.add(targetTr.dataset.name); - selectionChanged(); - render(); - return; - } - const [s, e] = last < idx ? [last, idx] : [idx, last]; - for (let i = s; i <= e; i++) selection.add(trs[i].dataset.name); - selectionChanged(); - if (currentView === 'graph' && !graphNeedsFullRender) updateGraph(); - else render(); -} - -// Graph rendering -function computeLevels(filteredOps) { - // longest path distance from any root (no deps referencing it). We'll build reverse adjacency. - const indegree = new Map(); - const deps = new Map(); - filteredOps.forEach((op) => { - deps.set(op.name, op.dependencies || []); - indegree.set(op.name, op.dependencies.length); - }); - // nodes with indegree 0 -> level 0 (bottom) - const queue = []; - indegree.forEach((v, k) => { - if (v === 0) queue.push(k); +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 }); - const level = new Map(); - queue.forEach((k) => level.set(k, 0)); - while (queue.length) { - const cur = queue.shift(); - const curLevel = level.get(cur) || 0; - // find dependents (reverse edges) - filteredOps.forEach((op) => { - if (op.dependencies.includes(cur)) { - indegree.set(op.name, indegree.get(op.name) - 1); - if (!level.has(op.name) || level.get(op.name) < curLevel + 1) level.set(op.name, curLevel + 1); - if (indegree.get(op.name) === 0) queue.push(op.name); - } - }); - } - return level; // bottom-up levels -} - -// Incremental graph rendering support ------------------------------------ -function getStatusColors() { - 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 = getStatusColors(); -// Recompute on theme change (if any custom property mutations occur) -const mo = new MutationObserver(() => { - statusColors = getStatusColors(); -}); -mo.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] }); -// Simple color dimming: blend toward backdrop (#1e293b) and optionally reduce saturation -function dimColor(hex, amount = 0.55) { - // Expect #rrggbb - 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); - // Backdrop (tailwind slate-800-ish) - const br = 30, - bg = 41, - bb = 59; // #1e293b - 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') - ); -} -// Compact graph layout constants -const GRAPH_NODE_WIDTH = 28; -const GRAPH_NODE_HEIGHT = 28; -const GRAPH_COL_WIDTH = 46; // horizontal step ( >= node width ) -const GRAPH_NODE_GAP = 10; // extra gap when multiple per level -const GRAPH_LEVEL_GAP = 70; // vertical distance between levels -const GRAPH_BASE_X = 16; -const GRAPH_BASE_Y = 16; -let graphNeedsFullRender = true; // set true on sync/filter changes -const graphState = { - nodePositions: new Map(), // name -> { x,y } - nodeStatus: new Map(), // name -> last displayStatus used for coloring - nodeElements: new Map(), // name -> div element - edgeElements: [] // array of { path, from, to } -}; - -function markGraphDirty() { - graphNeedsFullRender = true; - if (currentView === 'graph') ensureGraph(); -} -function ensureGraph() { - if (graphNeedsFullRender) { - buildGraph(); - graphNeedsFullRender = false; - } else { - updateGraph(); - } -} - -function buildGraph() { - // Clear existing - graphState.nodePositions.clear(); - graphState.nodeStatus.clear(); - graphState.nodeElements.forEach((el) => el.remove()); - graphState.nodeElements.clear(); - graphState.edgeElements.forEach((e) => e.path.remove()); - graphState.edgeElements.length = 0; - // Remove any existing marquee from previous renders - if (graphMarqueeEl) { - graphMarqueeEl.remove(); - graphMarqueeEl = null; - } - // Recreate markers - edgesSvg.innerHTML = - '' + - Object.entries(statusColors) - .map( - ([s, c]) => - `` - ) - .join('') + - ''; - const filteredOpsArr = computeGraphOperations(); - const level = computeLevels(filteredOpsArr); - const groups = {}; - level.forEach((l, name) => (groups[l] || (groups[l] = [])).push(name)); - const sortedLevels = Object.keys(groups) - .map(Number) - .sort((a, b) => a - b); - const levelGap = GRAPH_LEVEL_GAP; - const nodeGap = GRAPH_NODE_GAP; - const baseY = GRAPH_BASE_Y; - const baseX = GRAPH_BASE_X; - const colWidth = GRAPH_COL_WIDTH; - const maxLevel = sortedLevels.length ? Math.max(...sortedLevels) : 0; - // Precompute metrics for ordering within each level - // 1. Critical path length (max distance to any sink / number of edges in the longest path downstream). - // 2. Number of consumers (direct dependents) within the filtered/pruned graph. - const dependentsMap = new Map(); // name -> Set of direct dependents - filteredOpsArr.forEach((op) => { - (op.dependencies || []).forEach((d) => { - if (!operations.has(d)) return; - (dependentsMap.get(d) || dependentsMap.set(d, new Set()).get(d)).add(op.name); - }); - }); - // Build adjacency (forward edges) for DFS of downstream paths within filtered set only - const byNameFiltered = new Map(filteredOpsArr.map((o) => [o.name, o])); - const memoCpl = new Map(); - function criticalPathLen(name) { - if (memoCpl.has(name)) return memoCpl.get(name); - const op = byNameFiltered.get(name); - if (!op) { - memoCpl.set(name, 0); - return 0; - } - const deps = Array.from(dependentsMap.get(name) || []); - if (!deps.length) { - memoCpl.set(name, 0); - return 0; - } - let best = 0; - for (const d of deps) { - best = Math.max(best, 1 + criticalPathLen(d)); - } - memoCpl.set(name, best); - return best; - } - sortedLevels.forEach((l) => { - const nodes = groups[l]; - // Sort using metrics: critical path length DESC, consumers count DESC, name ASC - nodes.sort((a, b) => { - 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 = maxLevel - l; - nodes.forEach((name, j) => { - const op = operations.get(name); - if (!op) return; - const x = baseX + j * (colWidth + nodeGap); // left-aligned, no centering offset - const y = baseY + levelIndexFromTop * levelGap; - 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 = statusEmoji(computeDisplayStatus(op)); - div.appendChild(emojiSpan); - // Enabled-state superscript (will be populated in updateGraph) - const enabledSup = document.createElement('span'); - enabledSup.className = 'enabled-indicator'; - enabledSup.textContent = ''; // set later - div.appendChild(enabledSup); - div.addEventListener('click', (e) => { - e.stopPropagation(); - if (e.metaKey || e.ctrlKey) toggleSelect(name); - else singleSelect(name); - }); - graphEl.appendChild(div); - graphState.nodePositions.set(name, { x, y }); - graphState.nodeElements.set(name, div); - }); + wireLeftBarActions({ + sendCommand, + getSelection: () => selection, + clearSelectionAndRender: () => { + if (!selection.size) return; + selection.clear(); + render(); + }, + expandSelectionDependencies, + expandSelectionConsumers }); - // Build edges with redundant edge pruning. - // Heuristic transitive reduction: For edge A->C, if there exists a dependency B where A->B and B can reach C, omit A->C. - const byName = new Map(filteredOpsArr.map((o) => [o.name, o])); - const memoReach = new Map(); // name -> Set of reachable deps (transitive) - function getReachable(name) { - if (memoReach.has(name)) return memoReach.get(name); - const op = byName.get(name); - const visited = new Set(); - if (op) { - const stack = [...(op.dependencies || [])]; - while (stack.length) { - const d = stack.pop(); - if (visited.has(d)) continue; - visited.add(d); - const dop = byName.get(d); - if (dop) stack.push(...(dop.dependencies || [])); - } - } - memoReach.set(name, visited); - return visited; - } - const edgeRecords = []; - for (const op of filteredOpsArr) { - const deps = op.dependencies || []; - for (const depName of deps) { - if (!byName.has(depName)) continue; - // Check redundancy: if any sibling dependency path makes this direct edge unnecessary - let redundant = false; - for (const intermediate of deps) { - if (intermediate === depName) continue; - if (!byName.has(intermediate)) continue; - const reach = getReachable(intermediate); - if (reach.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 }); - edgesSvg.appendChild(path); - } - } - graphState.edgeElements = edgeRecords; - // After structural build, do first status+style update - updateGraph(); - // Resize svg - 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)) + - levelGap + - GRAPH_NODE_HEIGHT; - edgesSvg.setAttribute('width', maxX); - edgesSvg.setAttribute('height', maxY); - } -} - -function computeDisplayStatus(op) { - // prefer dynamic execution state status if available - const state = executionStates.get(op.name); - let displayStatus = (state && state.status) || op.status; - const runInThisIteration = state ? state.runInThisIteration : op.runInThisIteration; - if (runInThisIteration === false) { - const prev = lastExecutionResults.get(op.name); - if (prev && prev.status) displayStatus = prev.status; - else displayStatus = 'Skipped'; - } - // If operation has never executed (no current state and no previous result), mark as Unknown - if (!displayStatus) { - const last = lastExecutionResults.get(op.name); - if (!last || !last.status) { - displayStatus = 'Unknown'; - } - } - return displayStatus; -} - -function enabledGlyph(op) { - if (op.noop) return 'βšͺ'; - switch (op.enabled) { - case 'never': - return 'πŸ”΄'; - case 'ignore-dependency-changes': - return '🟑'; - case 'affected': - default: - return '🟒'; - } -} - -function buildRunPolicyText(op) { - if (op.noop) return 'Operation does no work'; - switch (op.enabled) { - case 'never': - return 'Never run'; - case 'ignore-dependency-changes': - return 'Ignores dependency changes'; // legacy + new unified - case 'affected': - default: - return 'Run if affected'; - } -} - -function buildTooltip(op, lastResultStatus) { - const policy = buildRunPolicyText(op); - const activeLine = op.isActive ? '\nHas in-memory state' : ''; - return `${op.name}\nLast Result: ${lastResultStatus}\n${policy}${activeLine}`; -} - -function updateGraph() { - // Update nodes (emoji + border color only) - for (const [name, div] of graphState.nodeElements.entries()) { - const op = operations.get(name); - if (!op) continue; - const displayStatus = computeDisplayStatus(op); - const prevStatus = graphState.nodeStatus.get(name); - const state = executionStates.get(name); - const runInThisIteration = state ? state.runInThisIteration : op.runInThisIteration; - const notRunning = runInThisIteration === false || op.noop; - const queuedState = queuedStates.get(name); - const isQueuedNext = !!(queuedState && queuedState.runInThisIteration === true); - const isFilteredOut = filteredOutNames.has(name); - const isSearchFiltered = searchFilteredOutNames.has(name); - const emojiSpan = div.querySelector('.emoji'); - if (emojiSpan && (prevStatus !== displayStatus || !emojiSpan.textContent)) { - emojiSpan.textContent = statusEmoji(displayStatus); - } - // Enabled-state superscript indicator logic - const enabledSpan = div.querySelector('.enabled-indicator'); - 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'; - // Apply dimming to border color for filtered states (search stronger than status filter) - if (isSearchFiltered) baseColor = dimColor(baseColor, 0.72); - else if (isFilteredOut) baseColor = dimColor(baseColor, 0.6); - else if (notRunning) baseColor = dimColor(baseColor, 0.35); // subtle dim for not-in-iteration - div.style.borderColor = baseColor; - // selection & run-state styling - if (selection.has(name)) div.classList.add('selected'); - else div.classList.remove('selected'); - // Active indicator (rocket bottom-left) - let activeSpan = div.querySelector('.active-indicator'); - 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(); - } - // Pending changes indicator (queued for iteration) shows clock top-left - let pendingSpan = div.querySelector('.pending-indicator'); - 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(); - } - // Clear style classes first (we no longer dim node background, only border & text) - div.classList.remove('not-running', 'filtered-out', 'filtered-out-search', 'dashed', 'dotted'); - // Apply filters first; search filter strongest fade, then status filter, else not-running state - if (isSearchFiltered) { - div.classList.add('filtered-out-search'); - } else if (isFilteredOut) { - div.classList.add('filtered-out'); - } else if (notRunning) { - div.classList.add('not-running'); - } - // Enabled indicator inherits node opacity; no special handling needed. - // queued-next styling removed; rely solely on clock indicator - // Tooltip for context (name + status + optional active note) - div.title = buildTooltip(op, displayStatus); - graphState.nodeStatus.set(name, displayStatus); - } - // Update edges - for (const rec of graphState.edgeElements) { - const fromPos = graphState.nodePositions.get(rec.from); - const toPos = graphState.nodePositions.get(rec.to); - if (!fromPos || !toPos) continue; - // Anchor points: bottom-center of source -> top-center of destination - const startX = fromPos.x + GRAPH_NODE_WIDTH / 2; - const startY = fromPos.y + GRAPH_NODE_HEIGHT; // bottom of source - const endX = toPos.x + GRAPH_NODE_WIDTH / 2; - const endY = toPos.y; // top of destination - - // Compute how many rows apart they are using the level gap constant - const rowsApart = Math.max(1, Math.round((toPos.y - fromPos.y) / GRAPH_LEVEL_GAP)); - - // Horizontal column step between node centers - const colStep = GRAPH_COL_WIDTH + GRAPH_NODE_GAP; - - // Helper that emits a quadratic curve path segment from (sx,sy) to (ex,ey) - // with control points offset vertically from endpoints by 1/4 of vertical distance - function quadratic(sx, sy, ex, ey) { - const mx = (sx + ex) / 2; - const my = (sy + ey) / 2; - // Vertical offset magnitude for endpoint control points - const baseOffset = (ey - sy) / 4; - return `Q ${sx} ${sy + baseOffset} ${mx} ${my} ${ex} ${my + baseOffset} ${ex} ${ey}`; - } - - let d = ''; - if (startX === endX && rowsApart === 1) { - // Direct vertical line from bottom of source to top of destination - d = `M ${startX} ${startY} L ${endX} ${endY}`; - } else if (rowsApart === 1) { - // Exactly one row apart but different x: curve to exact midpoint then to dest - // We'll go from bottom of source -> midpoint -> top of dest using two cubic beziers - d = `M ${startX} ${startY} ` + quadratic(startX, startY, endX, endY); - } else { - // More than one row apart: build S-curve to an intermediate x (half-column shifted), - // vertical travel, then S-curve into destination. - const dir = Math.sign(endX - startX) || 1; // default to positive X if aligned - const halfColShift = colStep / 2; - // Prefer roughly halfway horizontally between source and destination - const candidateX = startX + (endX - startX) * 0.5; - // Determine number of column steps between the two nodes - const deltaCols = Math.max(1, Math.round(Math.abs(endX - startX) / colStep)); - // If the candidate lands too close to any column center (i.e. inside a node), - // nudge it by half a column into the gap between columns in the direction of travel. - 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; - - // Y where we first arrive after the S-curve from the source: - // set to the top Y coordinate of the row immediately below the source. - // This equals origin row top (fromPos.y) + GRAPH_LEVEL_GAP. - const firstTargetY = fromPos.y + GRAPH_LEVEL_GAP; - - // Y at which we stop vertical travel: bottom of the row directly above destination - const bottomOfRowAboveDest = toPos.y - GRAPH_LEVEL_GAP + GRAPH_NODE_HEIGHT; - - // Ensure we don't compute inverted vertical ranges - const midY1 = firstTargetY; - const midY2 = bottomOfRowAboveDest; - - // Build pieces: - // 1) S-curve from start -> (intermediateX, midY1) - d = `M ${startX} ${startY} ` + quadratic(startX, startY, intermediateX, midY1); - // 2) Vertical line from midY1 -> midY2 at intermediateX - d += ` L ${intermediateX} ${midY2}`; - // 3) S-curve from (intermediateX, midY2) -> destination top - 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 = operations.get(rec.from); - if (selection.has(rec.to) && selection.has(rec.from)) rec.path.classList.add('highlight'); - else rec.path.classList.remove('highlight'); - // Apply style classes - rec.path.classList.remove('dashed', 'dotted', 'filtered-out', 'not-running'); - rec.path.classList.remove('filtered-out-search'); - const fromState = fromOp ? executionStates.get(rec.from) : undefined; - const fromRunInThisIteration = fromState ? fromState.runInThisIteration : fromOp?.runInThisIteration; - const fromNotRunning = fromOp && (fromRunInThisIteration === false || fromOp.noop); - const edgeStatusFiltered = filteredOutNames.has(rec.from) || filteredOutNames.has(rec.to); - const edgeSearchFiltered = searchFilteredOutNames.has(rec.from) || searchFilteredOutNames.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 { - // Clear any explicit opacity set by filtering rules - rec.path.style.opacity = ''; - // Dim edges that are not semantically important (not executing or failed) - const semImportant = depStatus === 'Executing' || depStatus === 'Failure'; - if (!semImportant && !rec.path.classList.contains('highlight')) { - rec.path.classList.add('dim'); - } else { - rec.path.classList.remove('dim'); - } - } - } - // Update phase pane after node/edge status refresh - renderPhasePane(); - renderLegend(); - updateSelectionUI(); -} -// Selection bar management -function updateSelectionUI() { - const bar = document.getElementById('selection-bar'); - if (!bar) return; - const hasSel = selection.size > 0; - // Always visible: heading shows context; selection-only buttons hidden if none - bar.style.display = 'flex'; - const headingSpan = document.getElementById('view-heading-text'); - if (headingSpan) { - headingSpan.textContent = currentView === 'graph' ? 'Dependency Graph' : 'Operations Table'; - } - const selectionButtonsIds = [ - 'invalidate-btn', - 'close-runners-btn', - 'set-enabled-default-btn', - 'set-enabled-ignore-deps-btn', - 'set-enabled-disabled-btn', - 'expand-deps-btn', - 'expand-consumers-btn' - ]; - selectionButtonsIds.forEach((id) => { - const el = document.getElementById(id); - if (el) { - // Enable only if we have selection AND we are connected (ws open) - const connected = ws && ws.readyState === WebSocket.OPEN; - el.disabled = !(hasSel && connected); - } + wireViewBar({ + getView: () => currentView, + setView: (nextView: DashboardView) => { + currentView = nextView; + }, + getFilter: () => currentFilter, + setFilter: (nextFilter: DashboardFilter) => { + currentFilter = nextFilter; + }, + setSearchQuery: (nextSearchQuery: string) => { + searchQuery = nextSearchQuery; + }, + markGraphDirty, + render }); - const clearBtn = document.getElementById('clear-selection-btn'); - if (clearBtn) { - const connected = ws && ws.readyState === WebSocket.OPEN; - clearBtn.disabled = !(hasSel && connected); - } - const countSpan = document.getElementById('selection-count'); - if (countSpan) { - const n = selection.size; - countSpan.textContent = n + (n === 1 ? ' selected' : ' selected'); - } - if (clearBtn) { - clearBtn.title = 'Clear selection'; - clearBtn.setAttribute('aria-label', 'Clear selection'); - } } +function init(): void { + const urlState = loadDashboardUrlState(window.location.search); + currentView = urlState.view; + currentFilter = urlState.filter; -function selectionChanged() { - // Immediate visibility update for selection bar to reduce perceived lag. + wireActions(); + updateDerivedUrlDisplay(); updateSelectionUI(); -} - -function expandSelectionDependencies() { - if (!selection.size) return; - const queue = [...selection]; - const seen = new Set(selection); - while (queue.length) { - const name = queue.shift(); - const op = 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); - } - } - } - const changed = seen.size !== selection.size; - selection = seen; - if (changed) { - selectionChanged(); - if (currentView === 'graph') ensureGraph(); - render(); - } -} - -function expandSelectionConsumers() { - if (!selection.size) return; - // Build reverse adjacency once - const dependents = new Map(); - for (const op of operations.values()) { - for (const dep of op.dependencies || []) { - if (!operations.has(dep)) continue; - (dependents.get(dep) || dependents.set(dep, new Set()).get(dep)).add(op.name); - } - } - const queue = [...selection]; - const seen = new Set(selection); - while (queue.length) { - const name = queue.shift(); - const deps = dependents.get(name); - if (!deps) continue; - for (const consumer of deps) { - if (!seen.has(consumer)) { - seen.add(consumer); - queue.push(consumer); - } - } - } - const changed = seen.size !== selection.size; - selection = seen; - if (changed) { - selectionChanged(); - if (currentView === 'graph') ensureGraph(); - render(); - } -} - -// --- Drag (marquee) selection for graph view ------------------------- -let graphMarqueeEl = null; -let dragSelecting = false; -let dragStart = null; // {x,y} in graphEl coords -let dragLast = null; // last mouse move pos -let preDragSelection = null; // Set of names existing before drag - -function graphPointFromEvent(e) { - const rect = graphEl.getBoundingClientRect(); - return { x: e.clientX - rect.left + graphEl.scrollLeft, y: e.clientY - rect.top + graphEl.scrollTop }; -} - -function beginDragSelection(e) { - if (currentView !== 'graph') return; - // Only left button - if (e.button !== 0) return; - // Avoid starting when clicking directly on a node (node click logic will handle single selection) - if (e.target && e.target.closest && e.target.closest('.op-node')) return; - dragSelecting = true; - dragStart = graphPointFromEvent(e); - dragLast = dragStart; - preDragSelection = new Set(selection); - graphMarqueeEl = document.createElement('div'); - graphMarqueeEl.className = 'graph-marquee'; - graphEl.appendChild(graphMarqueeEl); - updateMarquee(); - e.preventDefault(); -} - -function updateMarquee() { - if (!dragSelecting || !graphMarqueeEl || !dragStart || !dragLast) return; - const x1 = Math.min(dragStart.x, dragLast.x); - const y1 = Math.min(dragStart.y, dragLast.y); - const x2 = Math.max(dragStart.x, dragLast.x); - const y2 = 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'; - // Hit test nodes - const newlySelected = new Set(); - for (const [name, pos] of graphState.nodePositions.entries()) { - // Node bounding box - const nx1 = pos.x; - const ny1 = pos.y; - const nx2 = pos.x + GRAPH_NODE_WIDTH; - const ny2 = pos.y + GRAPH_NODE_HEIGHT; - if (nx2 < x1 || nx1 > x2 || ny2 < y1 || ny1 > y2) continue; // no intersect - newlySelected.add(name); - } - // Determine selection behavior - // Default: replace with marquee set; If shift/meta/ctrl: add; If alt: subtract - let finalSelection; - if (dragModifierMode === 'add') { - finalSelection = new Set(preDragSelection); - newlySelected.forEach((n) => finalSelection.add(n)); - } else if (dragModifierMode === 'subtract') { - finalSelection = new Set(preDragSelection); - newlySelected.forEach((n) => finalSelection.delete(n)); - } else { - finalSelection = newlySelected; - } - selection = finalSelection; - updateGraph(); // incremental refresh without full rebuild -} - -let dragModifierMode = 'replace'; // 'replace' | 'add' | 'subtract' - -function updateDragModifierMode(e) { - if (e.altKey) dragModifierMode = 'subtract'; - else if (e.metaKey || e.ctrlKey || e.shiftKey) dragModifierMode = 'add'; - else dragModifierMode = 'replace'; -} - -graphEl.addEventListener('mousedown', (e) => { - updateDragModifierMode(e); - beginDragSelection(e); -}); -graphEl.addEventListener('mousemove', (e) => { - if (!dragSelecting) return; - dragLast = graphPointFromEvent(e); - updateDragModifierMode(e); - updateMarquee(); - e.preventDefault(); -}); -window.addEventListener('mouseup', (e) => { - if (!dragSelecting) return; - dragSelecting = false; - if (graphMarqueeEl) { - graphMarqueeEl.remove(); - graphMarqueeEl = null; - } - dragStart = null; - dragLast = null; - preDragSelection = null; -}); -// Keep modifier mode in sync if user changes keys mid-drag outside graph area -window.addEventListener('keydown', (e) => { - if (dragSelecting) { - updateDragModifierMode(e); - updateMarquee(); - } -}); -window.addEventListener('keyup', (e) => { - if (dragSelecting) { - updateDragModifierMode(e); - updateMarquee(); - } -}); - -// Phase summary pane ---------------------------------------------------- -const phaseStatusPriority = [ - 'Failure', - 'SuccessWithWarning', - 'Blocked', - 'Aborted', - 'Executing', - 'Queued', - 'Ready', - 'Waiting', - 'Success', - 'Skipped', - 'FromCache', - 'NoOp' -]; -const phaseStatusPriorityIndex = new Map(phaseStatusPriority.map((s, i) => [s, i])); - -function computePhaseSummaries() { - const byPhase = new Map(); // phase -> { ops: [], statusSet: Set } - const graphMembership = - graphState && graphState.nodePositions.size ? new Set(graphState.nodePositions.keys()) : null; - for (const op of operations.values()) { - const phase = op.phaseName || '(none)'; - const displayStatus = computeDisplayStatus(op); - // Always include Executing operations so they are visible even if filtered out of the graph (e.g. failed-warn filter) - 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 = []; - for (const [phase, rec] of byPhase.entries()) { - // Determine most significant status by lowest index in priority list - let chosen = null; - let bestIdx = Infinity; - for (const s of rec.statusSet) { - const idx = phaseStatusPriorityIndex.get(s); - if (idx !== undefined && idx < bestIdx) { - bestIdx = idx; - chosen = s; - } - } - if (!chosen) { - chosen = 'Ready'; - } - // Problem operations list includes failures, warnings, and currently executing operations - 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; -} - -function renderPhasePane() { - if (!phaseGroupsEl) return; - const summaries = computePhaseSummaries(); - phaseGroupsEl.innerHTML = ''; - if (!summaries.length) { - const empty = document.createElement('div'); - empty.className = 'phase-pane-empty'; - empty.textContent = 'No phases'; - phaseGroupsEl.appendChild(empty); - return; - } - for (const s of summaries) { - const div = document.createElement('div'); - div.className = 'phase-group'; - const header = document.createElement('div'); - header.className = 'phase-header'; - const emoji = document.createElement('span'); - emoji.className = 'phase-status-emoji'; - emoji.textContent = statusEmoji(s.status); - const nameSpan = document.createElement('span'); - nameSpan.className = 'phase-name'; - // Only strip the exact leading `_phase:` prefix if present - nameSpan.textContent = s.phase.replace(/^_phase:/, ''); - header.appendChild(emoji); - header.appendChild(nameSpan); - div.appendChild(header); - if (s.problemOps.length) { - const ul = document.createElement('ul'); - ul.className = 'phase-problems'; - // Sort by status priority, then by operation name for stability - const sortedProblems = [...s.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 li = document.createElement('li'); - const emo = document.createElement('span'); - emo.className = 'phase-problem-emoji'; - emo.textContent = statusEmoji(displayStatus); - li.appendChild(emo); - const logUrl = - (op.logFileURLs && (op.logFileURLs.text || op.logFileURLs.error || op.logFileURLs.jsonl)) || null; - if (logUrl) { - const a = document.createElement('a'); - a.href = logUrl; - a.target = '_blank'; - a.rel = 'noopener noreferrer'; - a.textContent = op.name; - li.appendChild(a); - } else { - const span = document.createElement('span'); - span.textContent = op.name; - li.appendChild(span); - } - ul.appendChild(li); - } - div.appendChild(ul); - } - phaseGroupsEl.appendChild(div); - } -} - -// Legend --------------------------------------------------------------- -// Reuse the same priority ordering as phase summaries for legend ordering -const legendOrder = [...phaseStatusPriority]; -function renderLegend() { - if (!legendEl) return; - if (!legendEl._initialized) { - const header = document.createElement('h4'); - header.textContent = 'Legend'; - // Collapse toggle button - 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; - // Restore collapsed state - const collapsed = 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'); - // Re-render to hide/show content - renderLegend(); - }); - } - while (legendEl.children.length > 1) legendEl.removeChild(legendEl.lastChild); - const collapsedNow = legendEl.classList.contains('collapsed'); - if (collapsedNow) { - // Minimal stub when collapsed - const stub = document.createElement('div'); - stub.style.fontSize = '0.5rem'; - stub.style.opacity = '0.7'; - stub.textContent = 'Collapsed'; - legendEl.appendChild(stub); - return; - } - const columnsWrap = document.createElement('div'); - columnsWrap.className = 'legend-columns'; - // Primary column: core execution statuses - 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 = 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 = overallStatusText(status); - labelWrap.appendChild(titleSpan); - row.appendChild(sample); - row.appendChild(labelWrap); - colPrimary.appendChild(row); - } - // Unknown status (never executed) legend 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); - // Secondary column: run/enable/active/filter semantics - 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); - function addModifier(sampleFactory, label, detail) { - 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); - } - // Helper factories - const makeNodeBox = (borderStyle, boxShadow, borderColor) => { - const span = document.createElement('span'); - span.className = 'legend-emoji'; - if (borderStyle) span.style.borderStyle = borderStyle; - if (borderColor) span.style.borderColor = borderColor; - if (boxShadow) span.style.boxShadow = boxShadow; - span.textContent = ' '; // visual box only - return span; - }; - const makeQueued = () => makeNodeBox(null, '0 0 0 2px var(--warn) inset', 'var(--warn)'); - const makeDashed = () => { - const s = makeNodeBox('dashed'); - return s; - }; - const makeDotted = () => { - const s = makeNodeBox('dotted'); - return s; - }; - const makeActive = () => { - 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 = () => { - 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) => { - 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; + updateManagerState(); + updateStatusPill(); + connect(); + ( + window as Window & { + __rushServeDemo?: { operations: Map; selection: Set }; + } + ).__rushServeDemo = { + operations, + selection }; - 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'); - // Enabled state grouping heading - 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); -} - -const statusEmojiMap = { - Ready: '⏸️', - Waiting: 'πŸ•˜', - Queued: 'πŸ“', - Executing: 'βš™οΈ', - Success: 'βœ…', - SuccessWithWarning: '⚠️', - Skipped: 'πŸ’€', - FromCache: '🟩', - Failure: '❌', - Blocked: '🚫', - NoOp: 'πŸ’€', - Aborted: 'πŸ›‘', - Disconnected: '⏸️', - Unknown: '❓' -}; -function statusEmoji(status) { - return statusEmojiMap[status] || 'β€’'; } -// Table sorting -// (Removed old sortable column logic; pivoted view handles selection differently.) - -// Action buttons -document.getElementById('connect-btn').addEventListener('click', () => { - if (ws && ws.readyState === WebSocket.OPEN) { - disconnect(); - } else { - connect(); - } -}); -document - .getElementById('invalidate-btn') - .addEventListener('click', () => - sendCommand({ command: 'invalidate', operationNames: Array.from(selection) }) - ); -document - .getElementById('close-runners-btn') - .addEventListener('click', () => - sendCommand({ command: 'close-runners', operationNames: Array.from(selection) }) - ); -const expandDepsBtn = document.getElementById('expand-deps-btn'); -if (expandDepsBtn) expandDepsBtn.addEventListener('click', expandSelectionDependencies); -const expandConsumersBtn = document.getElementById('expand-consumers-btn'); -if (expandConsumersBtn) expandConsumersBtn.addEventListener('click', expandSelectionConsumers); -const setEnabledDefaultBtn = document.getElementById('set-enabled-default-btn'); -const setEnabledIgnoreDepsBtn = document.getElementById('set-enabled-ignore-deps-btn'); -const setEnabledDisabledBtn = document.getElementById('set-enabled-disabled-btn'); -const selectionModeBtn = document.getElementById('selection-mode-btn'); -let selectionEnableMode = 'safe'; // 'safe' | 'unsafe' -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.'; - }); -} -function sendEnableState(targetState) { - 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')); -document.getElementById('execute-btn').addEventListener('click', () => sendCommand({ command: 'execute' })); -document - .getElementById('abort-execution-btn') - .addEventListener('click', () => sendCommand({ command: 'abort-execution' })); -document.getElementById('clear-selection-btn').addEventListener('click', () => { - if (selection.size) { - selection.clear(); - render(); - } -}); -debugBtn.addEventListener('click', () => { - const newVal = !graphSettings?.debugMode; - // Optimistic UI update - debugBtn.title = newVal ? 'Turn off debug logging' : 'Turn on debug logging'; - sendCommand({ command: 'set-debug', value: newVal }); -}); -verboseBtn.addEventListener('click', () => { - const newVal = !graphSettings?.verbose; - verboseBtn.title = newVal ? 'Turn off verbose logging' : 'Turn on verbose logging'; - sendCommand({ command: 'set-verbose', value: newVal }); -}); -parallelismInput.addEventListener('change', () => - sendCommand({ command: 'set-parallelism', parallelism: Number(parallelismInput.value) || 1 }) -); -playPauseBtn.addEventListener('click', () => { - if (!graphSettings) return; - const next = !!graphSettings.pauseNextIteration; // current boolean - // Toggle: if currently paused (true), set to false (resume automatic); otherwise set to true (pause) - sendCommand({ command: 'set-pause-next-iteration', value: !next }); -}); -window.addEventListener('keydown', (e) => { - if (e.key === 'a' && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - selection = new Set(Array.from(operations.keys())); - render(); - } - if (e.key === 'Escape') { - if (selection.size) { - selection.clear(); - render(); - } - } -}); -// (Dynamic legend offset removed; static CSS handles scrollbar clearance.) - -// objFromSelection removed (replaced by sendEnableState helper for new API) - -// expose for debug -window.__rushServeDemo = { operations, selection }; -updateDerivedUrlDisplay(); - -// View toggle & filter handlers -document.querySelectorAll('input[name="view"]').forEach((r) => { - r.addEventListener('change', () => { - if (r.checked) { - currentView = r.value; - document.getElementById('left').style.display = currentView === 'table' ? '' : 'none'; - document.getElementById('right').style.display = currentView === 'graph' ? '' : 'none'; - syncUrlFromState(); - render(); - } - }); -}); -document.getElementById('filter-select').addEventListener('change', (e) => { - currentFilter = e.target.value; - // Filter change requires full rebuild of graph - markGraphDirty(); - syncUrlFromState(); - render(); -}); -const nameSearchInput = document.getElementById('name-search'); -nameSearchInput.addEventListener('input', () => { - searchQuery = nameSearchInput.value; - // Table re-render always; graph full rebuild (layout may shrink) when search changes. - markGraphDirty(); - render(); -}); -// Initialize visibility states -// Apply loaded state to controls (radio + filter) -const viewRadio = document.querySelector(`input[name="view"][value="${currentView}"]`); -if (viewRadio) viewRadio.checked = true; -const filterSelect = document.getElementById('filter-select'); -if (filterSelect) filterSelect.value = currentFilter; -document.getElementById('left').style.display = currentView === 'table' ? '' : 'none'; -document.getElementById('right').style.display = currentView === 'graph' ? '' : 'none'; -syncUrlFromState(); -// Auto-connect on load -updateDerivedUrlDisplay(); -connect(); +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 } From d7ba35a204f2d494a2c7262933e1cd41046db779 Mon Sep 17 00:00:00 2001 From: Mo Jazayeri Date: Tue, 28 Jul 2026 23:06:49 +0000 Subject: [PATCH 03/12] Fix dashboard default operation status --- .../rush-serve-plugin/src/dashboard/modules/statusHelpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/modules/statusHelpers.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/statusHelpers.ts index 8e8ac19d26f..713f89526f1 100644 --- a/rush-plugins/rush-serve-plugin/src/dashboard/modules/statusHelpers.ts +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/statusHelpers.ts @@ -53,7 +53,7 @@ export function computeDisplayStatus( if (!displayStatus) { const last: IOperationExecutionStateLike | undefined = lastExecutionResults.get(op.name); - if (!last || !last.status) displayStatus = 'Unknown'; + displayStatus = last?.status || (op.noop ? 'NoOp' : 'Ready'); } return displayStatus; From ff64007e056f28f726d782eb1ea8a80a57b43100 Mon Sep 17 00:00:00 2001 From: Mo Jazayeri Date: Wed, 29 Jul 2026 22:18:06 +0000 Subject: [PATCH 04/12] Fix dashboard lint with ESLint rules enforced Keep prefer-const, no-explicit-any, no-new-null, and no-control-regex enabled. Resolve their findings without eslint-disable suppressions. --- .../src/dashboard/dashboard.ts | 184 +++++++++--------- .../src/dashboard/modules/ansiSgrParser.ts | 49 +++-- .../dashboard/modules/dashboardWebSocket.ts | 27 ++- .../src/dashboard/modules/mainBar.ts | 15 +- .../src/dashboard/modules/terminalPane.ts | 24 +-- .../src/dashboard/modules/topBar.ts | 60 +++--- 6 files changed, 184 insertions(+), 175 deletions(-) diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/dashboard.ts b/rush-plugins/rush-serve-plugin/src/dashboard/dashboard.ts index a9db5d82e24..0c2e6510824 100644 --- a/rush-plugins/rush-serve-plugin/src/dashboard/dashboard.ts +++ b/rush-plugins/rush-serve-plugin/src/dashboard/dashboard.ts @@ -1,11 +1,6 @@ // 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, @@ -105,31 +100,41 @@ interface IGraphViewControllerLike { 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 statusPill: HTMLElement = document.getElementById('status-pill') as HTMLElement; +const statusEmojiEl: HTMLElement = document.getElementById('status-emoji') as HTMLElement; +const connectBtn: HTMLButtonElement | undefined = + (document.getElementById('connect-btn') as HTMLButtonElement | null) ?? undefined; +const appTitleEl: HTMLElement | undefined = document.getElementById('app-title') ?? undefined; +const tableEl: HTMLTableElement = document.getElementById('operations-table') as HTMLTableElement; +const tableHead: HTMLTableSectionElement | undefined = tableEl.querySelector('thead') ?? undefined; +const tableBody: HTMLTableSectionElement | undefined = tableEl.querySelector('tbody') ?? undefined; +const tableStats: HTMLElement | undefined = document.getElementById('table-stats') ?? undefined; +const managerStateEl: HTMLElement | undefined = document.getElementById('graph-state') ?? undefined; +const edgesSvg: SVGSVGElement = document.getElementById('edges') as unknown as SVGSVGElement; +const graphEl: HTMLElement = document.getElementById('graph') as HTMLElement; +const legendEl: HTMLElement | undefined = document.getElementById('graph-legend') ?? undefined; +const phasePaneEl: HTMLElement | undefined = document.getElementById('phase-pane') ?? undefined; +const phaseGroupsEl: HTMLElement | undefined = + phasePaneEl?.querySelector('.phase-groups') ?? undefined; +const playPauseBtn: HTMLButtonElement | undefined = + (document.getElementById('play-pause-btn') as HTMLButtonElement | null) ?? undefined; +const parallelismInput: HTMLInputElement | undefined = + (document.getElementById('parallelism-input') as HTMLInputElement | null) ?? undefined; +const debugBtn: HTMLButtonElement | undefined = + (document.getElementById('debug-btn') as HTMLButtonElement | null) ?? undefined; +const verboseBtn: HTMLButtonElement | undefined = + (document.getElementById('verbose-btn') as HTMLButtonElement | null) ?? undefined; +const terminalEl: HTMLElement | undefined = document.getElementById('terminal') ?? undefined; +const terminalBody: HTMLElement | undefined = document.getElementById('terminal-body') ?? undefined; +const termClearBtn: HTMLButtonElement | undefined = + (document.getElementById('term-clear-btn') as HTMLButtonElement | null) ?? undefined; +const termAutoScroll: HTMLInputElement | undefined = + (document.getElementById('term-autoscroll') as HTMLInputElement | null) ?? undefined; +const termAutoscrollBtn: HTMLButtonElement | undefined = + (document.getElementById('term-autoscroll-btn') as HTMLButtonElement | null) ?? undefined; +const toggleTerminalBtn: HTMLButtonElement | undefined = + (document.getElementById('toggle-terminal-btn') as HTMLButtonElement | null) ?? undefined; +const resizerEl: HTMLElement | undefined = document.getElementById('resizer') ?? undefined; const terminalPane: ITerminalPaneController = createTerminalPaneController({ terminalEl, @@ -169,12 +174,12 @@ const disabledControlIds: string[] = [ 'play-pause-btn' ]; -let operations: Map = new Map(); -let executionStates: Map = new Map(); -let queuedStates: Map = new Map(); +const operations: Map = new Map(); +const executionStates: Map = new Map(); +const queuedStates: Map = new Map(); let lastExecutionResults: Map = new Map(); let selection: Set = new Set(); -let graphSettings: IDashboardGraphState | null = null; +let graphSettings: IDashboardGraphState | undefined; let currentView: DashboardView = 'table'; let currentFilter: DashboardFilter = 'all'; let searchQuery: string = ''; @@ -186,7 +191,7 @@ function computeDisplayStatus(op: IOperationInfo): string { } function computeVisibleOperations(): IOperationInfo[] { - const result = computeFilterSetsCore({ + const result: ReturnType = computeFilterSetsCore({ operations, executionStates, currentFilter, @@ -198,7 +203,7 @@ function computeVisibleOperations(): IOperationInfo[] { return result.visibleOperations; } -const tableViewController = createTableViewController({ +const tableViewController: ReturnType = createTableViewController({ tableHead: tableHead || undefined, tableBody: tableBody || undefined, tableStats: tableStats || undefined, @@ -220,7 +225,7 @@ const tableViewController = createTableViewController({ overallStatusText }); -const phaseLegendController = createPhaseLegendController({ +const phaseLegendController: ReturnType = createPhaseLegendController({ phaseGroupsEl: phaseGroupsEl || undefined, legendEl: legendEl || undefined, getOperations: () => operations, @@ -232,13 +237,27 @@ const phaseLegendController = createPhaseLegendController({ getStatusColors }); -let graphSelectionController: ReturnType; - function selectionChanged(): void { updateSelectionUI(); render(); } +const graphSelectionController: ReturnType = + createGraphSelectionController({ + graphEl, + getCurrentView: () => currentView, + getSelection: () => selection, + setSelection: (nextSelection: Set) => { + selection = nextSelection; + }, + getOperations: () => operations, + getGraphNodePositions: () => graphState.nodePositions, + graphNodeWidth: 28, + graphNodeHeight: 28, + onSelectionChanged: selectionChanged, + onLiveSelectionChanged: updateGraph + }); + function singleSelect(name: string): void { graphSelectionController.singleSelect(name); } @@ -265,21 +284,6 @@ const graphViewController: IGraphViewControllerLike = createGraphViewController( 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 { @@ -318,10 +322,6 @@ function updateDerivedUrlDisplay(): void { updateTopBarDerivedUrlDisplay(connectBtn); } -function updateStatusPill(): void { - updateTopBarStatusPill(topBarRefs, socketController.getSocket(), graphSettings, statusEmoji); -} - function updateManagerState(): void { if (!graphSettings) return; updateTopBarManagerState(topBarRefs, graphSettings); @@ -329,37 +329,41 @@ function updateManagerState(): void { function log(message: string): void { const time: string = new Date().toLocaleTimeString(); - // eslint-disable-next-line no-console - console.log('[' + time + '] ' + message); + window.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 socketController: ReturnType = + createDashboardWebSocketController({ + getUrl: () => computeWebSocketUrl(window.location), + onConnecting: () => { + showConnectingStatus(statusPill, statusEmojiEl, statusEmoji); + }, + onConnectedStateChange: (connected: boolean) => { + setConnected(connected); + }, + onOpen: () => { + updateStatusPill(); + }, + onClose: () => { + updateStatusPill(); + }, + onError: (event: Event) => { + log('WebSocket error: ' + event.type); + }, + onParsedMessage: (message: unknown) => { + handleMessage(message as IDashboardMessage); + }, + onParseError: (error: unknown) => { + log('Bad JSON: ' + String(error)); + }, + onLog: log + }); + +function updateStatusPill(): void { + updateTopBarStatusPill(topBarRefs, socketController.getSocket(), graphSettings, statusEmoji); +} -const selectionBarController = createSelectionBarController({ +const selectionBarController: ReturnType = createSelectionBarController({ getSelection: () => selection, getCurrentView: () => currentView, isConnected: () => socketController.isConnected() @@ -384,10 +388,10 @@ function handleMessage(msg: IDashboardMessage): void { executionStates.clear(); applyExecutionStatesMutation(operations, executionStates, msg.currentExecutionStates || []); setQueuedStates(queuedStates, msg.queuedStates || []); - graphSettings = msg.graphState || null; + graphSettings = msg.graphState; lastExecutionResults = toLastExecutionResultsMap(msg.resultByOperation || []); if (appTitleEl && msg.sessionInfo) { - const title = `${msg.sessionInfo.actionName} β€” ${msg.sessionInfo.repositoryIdentifier}`; + const title: string = `${msg.sessionInfo.actionName} β€” ${msg.sessionInfo.repositoryIdentifier}`; appTitleEl.textContent = title; document.title = title; } @@ -400,7 +404,7 @@ function handleMessage(msg: IDashboardMessage): void { break; } case 'sync-graph-state': { - graphSettings = msg.graphState || null; + graphSettings = msg.graphState; break; } case 'iteration-scheduled': { @@ -494,7 +498,7 @@ function wireActions(): void { }); } function init(): void { - const urlState = loadDashboardUrlState(window.location.search); + const urlState: ReturnType = loadDashboardUrlState(window.location.search); currentView = urlState.view; currentFilter = urlState.filter; diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/modules/ansiSgrParser.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/ansiSgrParser.ts index bfed37ebff4..68ac598e6cd 100644 --- a/rush-plugins/rush-serve-plugin/src/dashboard/modules/ansiSgrParser.ts +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/ansiSgrParser.ts @@ -1,9 +1,6 @@ // 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; @@ -13,8 +10,8 @@ interface IAnsiState { bold: boolean; underline: boolean; inverse: boolean; - fg: string | null; - bg: string | null; + fg?: string; + bg?: string; } export class AnsiSgrParser { @@ -22,14 +19,13 @@ export class AnsiSgrParser { bold: false, underline: false, inverse: false, - fg: null, - bg: null + fg: undefined, + bg: undefined }; public process(input: string): IAnsiSegment[] { - const csiRegex: RegExp = /\x1b\[[0-9;]*m/g; - let match: RegExpExecArray | null; let lastIndex: number = 0; + let searchIndex: number = 0; const segments: IAnsiSegment[] = []; const pushSegmentIfText = (text: string): void => { @@ -38,20 +34,31 @@ export class AnsiSgrParser { segments.push({ text, style }); }; - while ((match = csiRegex.exec(input)) !== null) { - const idx: number = match.index; - if (idx > lastIndex) { - pushSegmentIfText(input.slice(lastIndex, idx)); + while (searchIndex < input.length) { + const escapeIndex: number = input.indexOf('\u001b[', searchIndex); + if (escapeIndex < 0) break; + + const suffixMatch: RegExpExecArray | undefined = + /^[0-9;]*m/.exec(input.slice(escapeIndex + 2)) ?? undefined; + if (!suffixMatch) { + searchIndex = escapeIndex + 2; + continue; + } + + if (escapeIndex > lastIndex) { + pushSegmentIfText(input.slice(lastIndex, escapeIndex)); } - const seq: string = match[0]; + const sequenceEnd: number = escapeIndex + 2 + suffixMatch[0].length; + const seq: string = input.slice(escapeIndex, sequenceEnd); try { this._applySgr(this._parseSgrParams(seq)); } catch { // Ignore malformed control sequences. } - lastIndex = csiRegex.lastIndex; + lastIndex = sequenceEnd; + searchIndex = sequenceEnd; } if (lastIndex < input.length) { @@ -81,8 +88,8 @@ export class AnsiSgrParser { this._state.bold = false; this._state.underline = false; this._state.inverse = false; - this._state.fg = null; - this._state.bg = null; + this._state.fg = undefined; + this._state.bg = undefined; } else if (p === 1) { this._state.bold = true; } else if (p === 4) { @@ -96,11 +103,11 @@ export class AnsiSgrParser { } else if (p >= 30 && p <= 37) { this._state.fg = this._sgrColorToCss(p - 30, false); } else if (p === 39) { - this._state.fg = null; + this._state.fg = undefined; } else if (p >= 40 && p <= 47) { this._state.bg = this._sgrColorToCss(p - 40, false); } else if (p === 49) { - this._state.bg = null; + this._state.bg = undefined; } else if (p >= 90 && p <= 97) { this._state.fg = this._sgrColorToCss(p - 90, true); } else if (p >= 100 && p <= 107) { @@ -109,7 +116,7 @@ export class AnsiSgrParser { } } - private _sgrColorToCss(idx: number, bright: boolean): string | null { + private _sgrColorToCss(idx: number, bright: boolean): string | undefined { const base: string[] = ['#000000', '#a00', '#0a0', '#aa0', '#00a', '#a0a', '#0aa', '#ddd']; const brightMap: string[] = [ '#555', @@ -121,7 +128,7 @@ export class AnsiSgrParser { '#55ffff', '#fff' ]; - return bright ? brightMap[idx] || base[idx] : base[idx] || null; + return bright ? brightMap[idx] || base[idx] : base[idx]; } private _ansiStateToStyle(state: IAnsiState): string { diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/modules/dashboardWebSocket.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/dashboardWebSocket.ts index b4202fcf001..83c6e9cb8dd 100644 --- a/rush-plugins/rush-serve-plugin/src/dashboard/modules/dashboardWebSocket.ts +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/dashboardWebSocket.ts @@ -1,9 +1,6 @@ // 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; @@ -21,35 +18,33 @@ export interface IDashboardWebSocketController { disconnect: () => void; sendCommand: (command: unknown) => void; isConnected: () => boolean; - getSocket: () => WebSocket | null; + getSocket: () => WebSocket | undefined; } export function createDashboardWebSocketController( options: IDashboardWebSocketControllerOptions ): IDashboardWebSocketController { - let ws: WebSocket | null = null; - let reconnectTimer: ReturnType | null = null; + let ws: WebSocket | undefined; + let reconnectTimer: ReturnType | undefined; let manualDisconnect: boolean = false; const isConnected = (): boolean => !!ws && ws.readyState === WebSocket.OPEN; - const getSocket = (): WebSocket | null => ws; - - let connect: () => void; + const getSocket = (): WebSocket | undefined => ws; const scheduleReconnect = (): void => { if (reconnectTimer) return; reconnectTimer = setTimeout(() => { - reconnectTimer = null; + reconnectTimer = undefined; if (!manualDisconnect) connect(); }, 4000); }; - connect = (): void => { + function connect(): void { if (ws && ws.readyState === WebSocket.OPEN) return; if (reconnectTimer) { clearTimeout(reconnectTimer); - reconnectTimer = null; + reconnectTimer = undefined; } manualDisconnect = false; @@ -78,7 +73,7 @@ export function createDashboardWebSocketController( ws.addEventListener('close', () => { options.onLog('Disconnected'); options.onConnectedStateChange(false); - ws = null; + ws = undefined; options.onClose(); if (!manualDisconnect) scheduleReconnect(); }); @@ -94,13 +89,13 @@ export function createDashboardWebSocketController( options.onParseError(error); } }); - }; + } const disconnect = (): void => { manualDisconnect = true; if (reconnectTimer) { clearTimeout(reconnectTimer); - reconnectTimer = null; + reconnectTimer = undefined; } if (ws) { try { @@ -108,7 +103,7 @@ export function createDashboardWebSocketController( } catch { // ignore close failures } - ws = null; + ws = undefined; } }; diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/modules/mainBar.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/mainBar.ts index 667627e67cd..d321d89b90a 100644 --- a/rush-plugins/rush-serve-plugin/src/dashboard/modules/mainBar.ts +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/mainBar.ts @@ -1,7 +1,6 @@ // 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 */ @@ -11,10 +10,10 @@ export interface IMainBarActionWiringOptions { isConnected: () => boolean; sendCommand: (cmd: any) => void; getGraphSettings: () => any; - debugBtn: HTMLElement | null; - verboseBtn: HTMLElement | null; - parallelismInput: HTMLInputElement | null; - playPauseBtn: HTMLElement | null; + debugBtn: HTMLElement | undefined; + verboseBtn: HTMLElement | undefined; + parallelismInput: HTMLInputElement | undefined; + playPauseBtn: HTMLElement | undefined; getOperationNames: () => string[]; setSelection: (next: Set) => void; clearSelection: () => void; @@ -40,7 +39,7 @@ export function wireMainBarActions(options: IMainBarActionWiringOptions): void { render } = options; - const connectBtn: HTMLElement | null = document.getElementById('connect-btn'); + const connectBtn: HTMLElement | undefined = document.getElementById('connect-btn') ?? undefined; if (connectBtn) { connectBtn.addEventListener('click', () => { if (isConnected()) { @@ -51,12 +50,12 @@ export function wireMainBarActions(options: IMainBarActionWiringOptions): void { }); } - const executeBtn: HTMLElement | null = document.getElementById('execute-btn'); + const executeBtn: HTMLElement | undefined = document.getElementById('execute-btn') ?? undefined; if (executeBtn) { executeBtn.addEventListener('click', () => sendCommand({ command: 'execute' })); } - const abortBtn: HTMLElement | null = document.getElementById('abort-execution-btn'); + const abortBtn: HTMLElement | undefined = document.getElementById('abort-execution-btn') ?? undefined; if (abortBtn) { abortBtn.addEventListener('click', () => sendCommand({ command: 'abort-execution' })); } diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/modules/terminalPane.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/terminalPane.ts index cd9e1942958..391b0eea46f 100644 --- a/rush-plugins/rush-serve-plugin/src/dashboard/modules/terminalPane.ts +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/terminalPane.ts @@ -1,7 +1,6 @@ // 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'; @@ -11,13 +10,13 @@ interface ITerminalElementWithState extends HTMLElement { } export interface ITerminalPaneRefs { - terminalEl: HTMLElement | null; - terminalBody: HTMLElement | null; - termClearBtn: HTMLElement | null; - termAutoScrollCheckbox: HTMLInputElement | null; - termAutoscrollBtn: HTMLElement | null; - toggleTerminalBtn: HTMLElement | null; - resizerEl: HTMLElement | null; + terminalEl: HTMLElement | undefined; + terminalBody: HTMLElement | undefined; + termClearBtn: HTMLElement | undefined; + termAutoScrollCheckbox: HTMLInputElement | undefined; + termAutoscrollBtn: HTMLElement | undefined; + toggleTerminalBtn: HTMLElement | undefined; + resizerEl: HTMLElement | undefined; } export interface ITerminalPaneController { @@ -70,7 +69,7 @@ function _appendChunk( } } -function _wireLayoutInit(terminalEl: HTMLElement | null): void { +function _wireLayoutInit(terminalEl: HTMLElement | undefined): void { try { if (terminalEl) { terminalEl.style.top = ''; @@ -80,7 +79,10 @@ function _wireLayoutInit(terminalEl: HTMLElement | null): void { } } -function _wireClearButton(terminalBody: HTMLElement | null, termClearBtn: HTMLElement | null): void { +function _wireClearButton( + terminalBody: HTMLElement | undefined, + termClearBtn: HTMLElement | undefined +): void { if (!terminalBody || !termClearBtn) return; termClearBtn.addEventListener('click', () => { @@ -88,7 +90,7 @@ function _wireClearButton(terminalBody: HTMLElement | null, termClearBtn: HTMLEl }); } -function _wireResizer(terminalEl: HTMLElement | null, resizerEl: HTMLElement | null): void { +function _wireResizer(terminalEl: HTMLElement | undefined, resizerEl: HTMLElement | undefined): void { if (!terminalEl || !resizerEl) return; const terminalWithState: ITerminalElementWithState = terminalEl as ITerminalElementWithState; diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/modules/topBar.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/topBar.ts index 07201249bc7..f7f7fd6a950 100644 --- a/rush-plugins/rush-serve-plugin/src/dashboard/modules/topBar.ts +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/topBar.ts @@ -1,18 +1,24 @@ // 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; + connectBtn: HTMLElement | undefined; + statusPill: HTMLElement | undefined; + statusEmojiEl: HTMLElement | undefined; + debugBtn: HTMLElement | undefined; + verboseBtn: HTMLElement | undefined; + playPauseBtn: HTMLElement | undefined; + parallelismInput: HTMLInputElement | undefined; + managerStateEl: HTMLElement | undefined; +} + +export interface ITopBarGraphState { + status?: string; + debugMode?: boolean; + verbose?: boolean; + pauseNextIteration?: boolean; + parallelism?: number | string; + hasScheduledIteration?: boolean; } export function overallStatusText(status: string | undefined): string { @@ -47,7 +53,7 @@ export function computeWsUrl(loc: Location): string { return proto + '//' + loc.host + '/ws'; } -export function updateDerivedUrlDisplay(connectBtn: HTMLElement | null): void { +export function updateDerivedUrlDisplay(connectBtn: HTMLElement | undefined): void { if (!connectBtn) return; const url: string = computeWsUrl(window.location); @@ -56,8 +62,8 @@ export function updateDerivedUrlDisplay(connectBtn: HTMLElement | null): void { } export function showConnectingStatus( - statusPill: HTMLElement | null, - statusEmojiEl: HTMLElement | null, + statusPill: HTMLElement | undefined, + statusEmojiEl: HTMLElement | undefined, statusEmoji: (status: string) => string ): void { if (!statusPill || !statusEmojiEl) return; @@ -69,8 +75,8 @@ export function showConnectingStatus( export function updateStatusPill( refs: ITopBarRefs, - ws: WebSocket | null, - graphSettings: any, + ws: WebSocket | undefined, + graphSettings: ITopBarGraphState | undefined, statusEmoji: (status: string) => string ): void { if (!refs.statusPill || !refs.statusEmojiEl) return; @@ -92,10 +98,9 @@ export function setConnected( updateSelectionUI: () => void, disabledControlIds: string[] ): void { - const connectBtnEl: HTMLElement | null = refs.connectBtn; - const iconSpan: HTMLElement | null = connectBtnEl - ? (connectBtnEl.querySelector('span.codicon') as HTMLElement | null) - : null; + const connectBtnEl: HTMLElement | undefined = refs.connectBtn; + const iconSpan: HTMLElement | undefined = + (connectBtnEl?.querySelector('span.codicon') as HTMLElement | null) ?? undefined; if (connectBtnEl) { if (connected) { @@ -113,17 +118,15 @@ export function setConnected( } disabledControlIds.forEach((id) => { - const el: HTMLButtonElement | HTMLInputElement | null = document.getElementById(id) as - | HTMLButtonElement - | HTMLInputElement - | null; + const el: HTMLButtonElement | HTMLInputElement | undefined = + (document.getElementById(id) as HTMLButtonElement | HTMLInputElement | null) ?? undefined; if (el) el.disabled = !connected; }); updateSelectionUI(); } -export function updateManagerState(refs: ITopBarRefs, graphSettings: any): void { +export function updateManagerState(refs: ITopBarRefs, graphSettings: ITopBarGraphState): void { if (!graphSettings) return; const { debugBtn, verboseBtn, playPauseBtn, parallelismInput, managerStateEl } = refs; @@ -142,9 +145,8 @@ export function updateManagerState(refs: ITopBarRefs, graphSettings: any): void verboseBtn.title = graphSettings.verbose ? 'Turn off verbose logging' : 'Turn on verbose logging'; } - const ppIcon: HTMLElement | null = playPauseBtn - ? (playPauseBtn.querySelector('.codicon') as HTMLElement | null) - : null; + const ppIcon: HTMLElement | undefined = + (playPauseBtn?.querySelector('.codicon') as HTMLElement | null) ?? undefined; if (playPauseBtn) { if (!graphSettings.pauseNextIteration) { playPauseBtn.classList.add('playing'); @@ -173,7 +175,7 @@ export function updateManagerState(refs: ITopBarRefs, graphSettings: any): void managerStateEl.innerHTML = ''; } - const executeBtn: HTMLElement | null = document.getElementById('execute-btn'); + const executeBtn: HTMLElement | undefined = document.getElementById('execute-btn') ?? undefined; if (executeBtn) { if (graphSettings.hasScheduledIteration) { executeBtn.classList.add('queued'); From ad77d174cd41dae3eb0bff441e0e8ca46b65b555 Mon Sep 17 00:00:00 2001 From: Mo Jazayeri Date: Thu, 30 Jul 2026 00:45:16 +0000 Subject: [PATCH 05/12] Add explicit dashboard module types Resolve typedef and no-explicit-any lint findings without ESLint suppressions. --- .../dashboard/modules/dashboardMutations.ts | 48 ++++- .../src/dashboard/modules/graphFiltering.ts | 52 +++-- .../src/dashboard/modules/graphSelection.ts | 10 +- .../src/dashboard/modules/graphView.ts | 198 +++++++++--------- .../src/dashboard/modules/leftBar.ts | 9 +- .../src/dashboard/modules/mainBar.ts | 16 +- .../src/dashboard/modules/phaseLegend.ts | 111 +++++----- .../src/dashboard/modules/terminalPane.ts | 4 +- .../src/dashboard/modules/viewBar.ts | 4 +- 9 files changed, 255 insertions(+), 197 deletions(-) diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/modules/dashboardMutations.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/dashboardMutations.ts index a4f2f36b8b4..c3c1f4bf251 100644 --- a/rush-plugins/rush-serve-plugin/src/dashboard/modules/dashboardMutations.ts +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/dashboardMutations.ts @@ -1,19 +1,39 @@ // 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 */ +interface IOperationLogFileURLs { + text?: string; + error?: string; + jsonl?: string; +} + +interface IOperationData { + name: string; + isActive?: boolean; + status?: string; + runInThisIteration?: boolean; + logFileURLs?: IOperationLogFileURLs; +} + +interface IStateEntry { + name: string; + isActive?: boolean; + status?: string; + runInThisIteration?: boolean; + logFileURLs?: IOperationLogFileURLs; +} export function applyExecutionStates( - operations: Map, - executionStates: Map, - stateArray: any[] + operations: Map, + executionStates: Map, + stateArray: IStateEntry[] ): void { if (!stateArray) return; stateArray.forEach((stateEntry) => { executionStates.set(stateEntry.name, stateEntry); - const op: any = operations.get(stateEntry.name); + const op: IOperationData | undefined = operations.get(stateEntry.name); if (!op) return; op.isActive = stateEntry.isActive; @@ -23,7 +43,7 @@ export function applyExecutionStates( }); } -export function setQueuedStates(queuedStates: Map, stateArray: any[]): void { +export function setQueuedStates(queuedStates: Map, stateArray: IStateEntry[]): void { queuedStates.clear(); if (!stateArray) return; @@ -32,17 +52,25 @@ export function setQueuedStates(queuedStates: Map, stateArray: any[ }); } -export function setOperationsFromPayload(operations: Map, operationArray: any[]): void { +export function setOperationsFromPayload( + operations: Map, + operationArray: IOperationData[] +): void { operations.clear(); operationArray.forEach((op) => operations.set(op.name, op)); } -export function patchOperationsFromPayload(operations: Map, operationArray: any[]): void { +export function patchOperationsFromPayload( + operations: Map, + operationArray: IOperationData[] +): void { operationArray.forEach((op) => operations.set(op.name, op)); } -export function toLastExecutionResultsMap(lastExecutionResultsArray: any[] | undefined): Map { - const result: Map = new Map(); +export function toLastExecutionResultsMap( + lastExecutionResultsArray: IStateEntry[] | undefined +): Map { + const result: Map = new Map(); if (!lastExecutionResultsArray) return result; lastExecutionResultsArray.forEach((entry) => { diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/modules/graphFiltering.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/graphFiltering.ts index dc7c899cfcb..66ba48fa8b8 100644 --- a/rush-plugins/rush-serve-plugin/src/dashboard/modules/graphFiltering.ts +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/graphFiltering.ts @@ -1,18 +1,40 @@ // 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 */ +interface IOperationLogFileURLs { + text?: string; + error?: string; + jsonl?: string; +} + +interface IOperationData { + name: string; + dependencies?: string[]; + noop?: boolean; + runInThisIteration?: boolean; + status?: string; + isActive?: boolean; + logFileURLs?: IOperationLogFileURLs; +} + +interface IExecutionState { + name: string; + status?: string; + runInThisIteration?: boolean; + isActive?: boolean; + logFileURLs?: IOperationLogFileURLs; +} export interface IComputeFilterSetsOptions { - operations: Map; - executionStates: Map; + operations: Map; + executionStates: Map; currentFilter: 'all' | 'failed-warn'; searchQuery: string; - computeDisplayStatus: (op: any) => string; + computeDisplayStatus: (op: IOperationData) => string; } export interface IComputeFilterSetsResult { - visibleOperations: any[]; + visibleOperations: IOperationData[]; filteredOutNames: Set; searchFilteredOutNames: Set; } @@ -22,17 +44,17 @@ export function computeFilterSetsCore(options: IComputeFilterSetsOptions): IComp const filteredOutNames: Set = new Set(); const searchFilteredOutNames: Set = new Set(); - const visibleOperations: any[] = []; + const visibleOperations: IOperationData[] = []; const query: string = searchQuery.trim().toLowerCase(); for (const op of operations.values()) { - const state: any = executionStates.get(op.name) || {}; + const state: IExecutionState | undefined = executionStates.get(op.name) || undefined; // Merge dynamic fields so rendering logic can treat operation rows uniformly. - op.runInThisIteration = state.runInThisIteration; - op.status = state.status || op.status; - op.isActive = state.isActive; - op.logFileURLs = state.logFileURLs; + 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') { @@ -59,10 +81,10 @@ export function computeFilterSetsCore(options: IComputeFilterSetsOptions): IComp }; } -export function pruneGraphOperations(baseOperations: any[]): any[] { +export function pruneGraphOperations(baseOperations: IOperationData[]): IOperationData[] { if (!baseOperations.length) return baseOperations; - const byName: Map = new Map(); + const byName: Map = new Map(); baseOperations.forEach((op) => byName.set(op.name, op)); const dependents: Map> = new Map(); @@ -104,7 +126,7 @@ export function pruneGraphOperations(baseOperations: any[]): any[] { if (!nodeName || !active.has(nodeName)) continue; active.delete(nodeName); - const op: any = byName.get(nodeName); + const op: IOperationData | undefined = byName.get(nodeName); if (!op) continue; for (const dependencyName of op.dependencies || []) { @@ -139,7 +161,7 @@ export function pruneGraphOperations(baseOperations: any[]): any[] { if (seen.has(nodeName)) return new Set(); seen.add(nodeName); - const op: any = byName.get(nodeName); + const op: IOperationData | undefined = byName.get(nodeName); const resolved: Set = new Set(); if (!op) return resolved; diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/modules/graphSelection.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/graphSelection.ts index e721da2f092..e7381664e45 100644 --- a/rush-plugins/rush-serve-plugin/src/dashboard/modules/graphSelection.ts +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/graphSelection.ts @@ -1,8 +1,6 @@ // 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[]; @@ -77,10 +75,10 @@ export function createGraphSelectionController( 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; + const nx1: number = pos.x; + const ny1: number = pos.y; + const nx2: number = pos.x + options.graphNodeWidth; + const ny2: number = pos.y + options.graphNodeHeight; if (nx2 < x1 || nx1 > x2 || ny2 < y1 || ny1 > y2) continue; newlySelected.add(name); } diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/modules/graphView.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/graphView.ts index 33139aa3fb7..47bd7d602d9 100644 --- a/rush-plugins/rush-serve-plugin/src/dashboard/modules/graphView.ts +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/graphView.ts @@ -1,8 +1,6 @@ // 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 { @@ -92,7 +90,7 @@ export interface IGraphViewController { export function createGraphViewController(options: IGraphViewControllerOptions): IGraphViewController { let graphNeedsFullRender: boolean = true; function getStatusColors(): Record { - const cs = getComputedStyle(document.documentElement); + const cs: CSSStyleDeclaration = getComputedStyle(document.documentElement); return { Ready: cs.getPropertyValue('--status-ready').trim(), Waiting: cs.getPropertyValue('--status-waiting').trim(), @@ -111,7 +109,7 @@ export function createGraphViewController(options: IGraphViewControllerOptions): let statusColors: Record = getStatusColors(); - const mo = new MutationObserver(() => { + const mo: MutationObserver = new MutationObserver(() => { statusColors = getStatusColors(); }); mo.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] }); @@ -137,9 +135,9 @@ export function createGraphViewController(options: IGraphViewControllerOptions): queue.forEach((k: string) => level.set(k, 0)); while (queue.length) { - const cur = queue.shift(); + const cur: string | undefined = queue.shift(); if (!cur) continue; - const curLevel = level.get(cur) || 0; + const curLevel: number = level.get(cur) || 0; filteredOps.forEach((op: IOperationInfo) => { if ((op.dependencies || []).includes(cur)) { indegree.set(op.name, (indegree.get(op.name) || 0) - 1); @@ -155,8 +153,8 @@ export function createGraphViewController(options: IGraphViewControllerOptions): } function computeGraphOperations(): IOperationInfo[] { - const filteredOutNames = options.getFilteredOutNames(); - const searchFilteredOutNames = options.getSearchFilteredOutNames(); + const filteredOutNames: Set = options.getFilteredOutNames(); + const searchFilteredOutNames: Set = options.getSearchFilteredOutNames(); const visibleOperations: IOperationInfo[] = []; for (const op of options.getOperations().values()) { @@ -168,18 +166,18 @@ export function createGraphViewController(options: IGraphViewControllerOptions): return pruneGraphOperations(visibleOperations); } - function dimColor(hex: string, amount = 0.55): string { + function dimColor(hex: string, amount: number = 0.55): string { if (!hex || !/^#?[0-9a-fA-F]{6}$/.test(hex)) return hex || '#4b5563'; if (hex[0] === '#') hex = hex.slice(1); - const r = 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); + const r: number = parseInt(hex.slice(0, 2), 16); + const g: number = parseInt(hex.slice(2, 4), 16); + const b: number = parseInt(hex.slice(4, 6), 16); + const br: number = 30; + const bg: number = 41; + const bb: number = 59; + const nr: number = Math.round(r * (1 - amount) + br * amount); + const ng: number = Math.round(g * (1 - amount) + bg * amount); + const nb: number = Math.round(b * (1 - amount) + bb * amount); return ( '#' + nr.toString(16).padStart(2, '0') + @@ -200,7 +198,7 @@ export function createGraphViewController(options: IGraphViewControllerOptions): '' + Object.entries(statusColors) .map( - ([status, color]) => + ([status, color]: [string, string]) => `` ) .join('') + @@ -219,7 +217,7 @@ export function createGraphViewController(options: IGraphViewControllerOptions): filteredOpsArr.forEach((op: IOperationInfo) => { (op.dependencies || []).forEach((dependencyName: string) => { if (!options.getOperations().has(dependencyName)) return; - let setForDependency = dependentsMap.get(dependencyName); + let setForDependency: Set | undefined = dependentsMap.get(dependencyName); if (!setForDependency) { setForDependency = new Set(); dependentsMap.set(dependencyName, setForDependency); @@ -233,9 +231,9 @@ export function createGraphViewController(options: IGraphViewControllerOptions): ); const memoCpl: Map = new Map(); function criticalPathLen(name: string): number { - const cached = memoCpl.get(name); + const cached: number | undefined = memoCpl.get(name); if (cached !== undefined) return cached; - const op = byNameFiltered.get(name); + const op: IOperationInfo | undefined = byNameFiltered.get(name); if (!op) { memoCpl.set(name, 0); return 0; @@ -245,7 +243,7 @@ export function createGraphViewController(options: IGraphViewControllerOptions): memoCpl.set(name, 0); return 0; } - let best = 0; + let best: number = 0; for (const dependencyName of deps) { best = Math.max(best, 1 + criticalPathLen(dependencyName)); } @@ -254,32 +252,32 @@ export function createGraphViewController(options: IGraphViewControllerOptions): } sortedLevels.forEach((levelValue: number) => { - const nodes = groups[levelValue] || []; + const nodes: string[] = groups[levelValue] || []; nodes.sort((a: string, b: string) => { - const cplA = criticalPathLen(a); - const cplB = criticalPathLen(b); + const cplA: number = criticalPathLen(a); + const cplB: number = criticalPathLen(b); if (cplA !== cplB) return cplB - cplA; - const consA = (dependentsMap.get(a) || new Set()).size; - const consB = (dependentsMap.get(b) || new Set()).size; + const consA: number = (dependentsMap.get(a) || new Set()).size; + const consB: number = (dependentsMap.get(b) || new Set()).size; if (consA !== consB) return consB - consA; return a.localeCompare(b); }); const levelIndexFromTop: number = maxLevel - levelValue; nodes.forEach((name: string, index: number) => { - const op = options.getOperations().get(name); + const op: IOperationInfo | undefined = 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'); + const x: number = GRAPH_BASE_X + index * (GRAPH_COL_WIDTH + GRAPH_NODE_GAP); + const y: number = GRAPH_BASE_Y + levelIndexFromTop * GRAPH_LEVEL_GAP; + const div: HTMLDivElement = document.createElement('div'); div.className = 'op-node'; div.dataset.name = name; div.style.transform = `translate(${x}px, ${y}px)`; - const emojiSpan = document.createElement('span'); + const emojiSpan: HTMLSpanElement = document.createElement('span'); emojiSpan.className = 'emoji'; emojiSpan.textContent = options.getStatusEmoji(options.getComputeDisplayStatus(op)); div.appendChild(emojiSpan); - const enabledSup = document.createElement('span'); + const enabledSup: HTMLSpanElement = document.createElement('span'); enabledSup.className = 'enabled-indicator'; enabledSup.textContent = ''; div.appendChild(enabledSup); @@ -299,18 +297,18 @@ export function createGraphViewController(options: IGraphViewControllerOptions): ); const memoReach: Map> = new Map>(); function getReachable(name: string): Set { - const cached = memoReach.get(name); + const cached: Set | undefined = memoReach.get(name); if (cached) return cached; - const op = byName.get(name); + const op: IOperationInfo | undefined = byName.get(name); const visited: Set = new Set(); if (op) { const stack: string[] = [...(op.dependencies || [])]; while (stack.length) { - const dependencyName = stack.pop(); + const dependencyName: string | undefined = stack.pop(); if (!dependencyName) continue; if (visited.has(dependencyName)) continue; visited.add(dependencyName); - const dependencyOp = byName.get(dependencyName); + const dependencyOp: IOperationInfo | undefined = byName.get(dependencyName); if (dependencyOp) stack.push(...(dependencyOp.dependencies || [])); } } @@ -320,10 +318,10 @@ export function createGraphViewController(options: IGraphViewControllerOptions): const edgeRecords: IGraphEdgeRecord[] = []; for (const op of filteredOpsArr) { - const deps = op.dependencies || []; + const deps: string[] = op.dependencies || []; for (const depName of deps) { if (!byName.has(depName)) continue; - let redundant = false; + let redundant: boolean = false; for (const intermediate of deps) { if (intermediate === depName) continue; if (!byName.has(intermediate)) continue; @@ -333,10 +331,10 @@ export function createGraphViewController(options: IGraphViewControllerOptions): } } if (redundant) continue; - const fromPos = graphState.nodePositions.get(op.name); - const toPos = graphState.nodePositions.get(depName); + const fromPos: IPoint | undefined = graphState.nodePositions.get(op.name); + const toPos: IPoint | undefined = graphState.nodePositions.get(depName); if (!fromPos || !toPos) continue; - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + const path: SVGPathElement = document.createElementNS('http://www.w3.org/2000/svg', 'path'); edgeRecords.push({ path, from: op.name, to: depName }); options.edgesSvg.appendChild(path); } @@ -345,9 +343,9 @@ export function createGraphViewController(options: IGraphViewControllerOptions): updateGraph(); if (graphState.nodePositions.size) { - const maxX = + const maxX: number = Math.max(...Array.from(graphState.nodePositions.values()).map((p) => p.x)) + GRAPH_NODE_WIDTH + 40; - const maxY = + const maxY: number = Math.max(...Array.from(graphState.nodePositions.values()).map((p) => p.y)) + GRAPH_LEVEL_GAP + GRAPH_NODE_HEIGHT; @@ -359,24 +357,28 @@ export function createGraphViewController(options: IGraphViewControllerOptions): function updateGraph(): void { updateStatusColors(); for (const [name, div] of graphState.nodeElements.entries()) { - const op = options.getOperations().get(name); + const op: IOperationInfo | undefined = 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'); + const displayStatus: string = options.getComputeDisplayStatus(op); + const prevStatus: string | undefined = graphState.nodeStatus.get(name); + const state: IOperationExecutionState | undefined = options.getExecutionStates().get(name); + const runInThisIteration: boolean | undefined = state + ? state.runInThisIteration + : op.runInThisIteration; + const notRunning: boolean = runInThisIteration === false || !!op.noop; + const queuedState: IOperationExecutionState | undefined = options.getQueuedStates().get(name); + const isQueuedNext: boolean = !!(queuedState && queuedState.runInThisIteration === true); + const isFilteredOut: boolean = options.getFilteredOutNames().has(name); + const isSearchFiltered: boolean = options.getSearchFilteredOutNames().has(name); + const emojiSpan: Element | null = div.querySelector('.emoji'); if (emojiSpan && (prevStatus !== displayStatus || !emojiSpan.textContent)) { emojiSpan.textContent = options.getStatusEmoji(displayStatus); } - const enabledSpan = div.querySelector('.enabled-indicator') as HTMLSpanElement | null; + const enabledSpan: HTMLSpanElement | null = div.querySelector( + '.enabled-indicator' + ) as HTMLSpanElement | null; if (enabledSpan) { - let indicator = ''; + let indicator: string = ''; if (op.noop) { indicator = 'βšͺ'; enabledSpan.title = 'No-op operation'; @@ -399,14 +401,16 @@ export function createGraphViewController(options: IGraphViewControllerOptions): } if (enabledSpan.textContent !== indicator) enabledSpan.textContent = indicator; } - let baseColor = statusColors[displayStatus] || '#4b5563'; + let baseColor: string = statusColors[displayStatus] || '#4b5563'; if (isSearchFiltered) baseColor = dimColor(baseColor, 0.72); else if (isFilteredOut) baseColor = dimColor(baseColor, 0.6); else if (notRunning) baseColor = dimColor(baseColor, 0.35); div.style.borderColor = baseColor; if (options.getSelection().has(name)) div.classList.add('selected'); else div.classList.remove('selected'); - let activeSpan = div.querySelector('.active-indicator') as HTMLSpanElement | null; + let activeSpan: HTMLSpanElement | null = div.querySelector( + '.active-indicator' + ) as HTMLSpanElement | null; if (op.isActive) { if (!activeSpan) { activeSpan = document.createElement('span'); @@ -418,7 +422,9 @@ export function createGraphViewController(options: IGraphViewControllerOptions): } else if (activeSpan) { activeSpan.remove(); } - let pendingSpan = div.querySelector('.pending-indicator') as HTMLSpanElement | null; + let pendingSpan: HTMLSpanElement | null = div.querySelector( + '.pending-indicator' + ) as HTMLSpanElement | null; if (isQueuedNext) { if (!pendingSpan) { pendingSpan = document.createElement('span'); @@ -443,69 +449,73 @@ export function createGraphViewController(options: IGraphViewControllerOptions): } for (const rec of graphState.edgeElements) { - const fromPos = graphState.nodePositions.get(rec.from); - const toPos = graphState.nodePositions.get(rec.to); + const fromPos: IPoint | undefined = graphState.nodePositions.get(rec.from); + const toPos: IPoint | undefined = 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; + const startX: number = fromPos.x + GRAPH_NODE_WIDTH / 2; + const startY: number = fromPos.y + GRAPH_NODE_HEIGHT; + const endX: number = toPos.x + GRAPH_NODE_WIDTH / 2; + const endY: number = toPos.y; + const rowsApart: number = Math.max(1, Math.round((toPos.y - fromPos.y) / GRAPH_LEVEL_GAP)); + const colStep: number = GRAPH_COL_WIDTH + GRAPH_NODE_GAP; function quadratic(sx: number, sy: number, ex: number, ey: number): string { - const mx = (sx + ex) / 2; - const my = (sy + ey) / 2; - const baseOffset = (ey - sy) / 4; + const mx: number = (sx + ex) / 2; + const my: number = (sy + ey) / 2; + const baseOffset: number = (ey - sy) / 4; return `Q ${sx} ${sy + baseOffset} ${mx} ${my} ${ex} ${my + baseOffset} ${ex} ${ey}`; } - let d = ''; + let d: string = ''; if (startX === endX && rowsApart === 1) { d = `M ${startX} ${startY} L ${endX} ${endY}`; } else if (rowsApart === 1) { d = `M ${startX} ${startY} ` + quadratic(startX, startY, endX, endY); } else { - const dir = 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; + const dir: number = Math.sign(endX - startX) || 1; + const halfColShift: number = colStep / 2; + const candidateX: number = startX + (endX - startX) * 0.5; + const deltaCols: number = Math.max(1, Math.round(Math.abs(endX - startX) / colStep)); + let intermediateX: number = candidateX; + let tooClose: boolean = false; + for (let k: number = 0; k <= deltaCols; k++) { + const center: number = startX + dir * k * colStep; if (Math.abs(candidateX - center) < GRAPH_NODE_WIDTH / 2 + 2) { tooClose = true; break; } } if (tooClose) intermediateX = candidateX + dir * halfColShift; - const firstTargetY = fromPos.y + GRAPH_LEVEL_GAP; - const bottomOfRowAboveDest = toPos.y - GRAPH_LEVEL_GAP + GRAPH_NODE_HEIGHT; - const midY1 = firstTargetY; - const midY2 = bottomOfRowAboveDest; + const firstTargetY: number = fromPos.y + GRAPH_LEVEL_GAP; + const bottomOfRowAboveDest: number = toPos.y - GRAPH_LEVEL_GAP + GRAPH_NODE_HEIGHT; + const midY1: number = firstTargetY; + const midY2: number = bottomOfRowAboveDest; d = `M ${startX} ${startY} ` + quadratic(startX, startY, intermediateX, midY1); d += ` L ${intermediateX} ${midY2}`; d += ' ' + quadratic(intermediateX, midY2, endX, endY); } rec.path.setAttribute('d', d); - const depStatus = graphState.nodeStatus.get(rec.to) || 'Ready'; + const depStatus: string = graphState.nodeStatus.get(rec.to) || 'Ready'; rec.path.setAttribute('stroke', statusColors[depStatus] || '#4b5563'); rec.path.setAttribute('class', 'edge'); rec.path.setAttribute('marker-end', `url(#arrowhead-${depStatus})`); - const fromOp = options.getOperations().get(rec.from); + const fromOp: IOperationInfo | undefined = options.getOperations().get(rec.from); if (options.getSelection().has(rec.to) && options.getSelection().has(rec.from)) rec.path.classList.add('highlight'); else rec.path.classList.remove('highlight'); rec.path.classList.remove('dashed', 'dotted', 'filtered-out', 'not-running'); rec.path.classList.remove('filtered-out-search'); - const fromState = fromOp ? options.getExecutionStates().get(rec.from) : undefined; - const fromRunInThisIteration = fromState ? fromState.runInThisIteration : fromOp?.runInThisIteration; - const fromNotRunning = fromOp && (fromRunInThisIteration === false || fromOp.noop); - const edgeStatusFiltered = + const fromState: IOperationExecutionState | undefined = fromOp + ? options.getExecutionStates().get(rec.from) + : undefined; + const fromRunInThisIteration: boolean | undefined = fromState + ? fromState.runInThisIteration + : fromOp?.runInThisIteration; + const fromNotRunning: boolean = !!(fromOp && (fromRunInThisIteration === false || fromOp.noop)); + const edgeStatusFiltered: boolean = options.getFilteredOutNames().has(rec.from) || options.getFilteredOutNames().has(rec.to); - const edgeSearchFiltered = + const edgeSearchFiltered: boolean = options.getSearchFilteredOutNames().has(rec.from) || options.getSearchFilteredOutNames().has(rec.to); if (edgeSearchFiltered || edgeStatusFiltered || fromNotRunning) { - let strokeColor = statusColors[depStatus] || '#4b5563'; + let strokeColor: string = statusColors[depStatus] || '#4b5563'; if (edgeSearchFiltered) { strokeColor = dimColor(strokeColor, 0.78); rec.path.style.opacity = '0.22'; @@ -520,7 +530,7 @@ export function createGraphViewController(options: IGraphViewControllerOptions): rec.path.setAttribute('marker-end', `url(#arrowhead-${depStatus})`); } else { rec.path.style.opacity = ''; - const semImportant = depStatus === 'Executing' || depStatus === 'Failure'; + const semImportant: boolean = depStatus === 'Executing' || depStatus === 'Failure'; if (!semImportant && !rec.path.classList.contains('highlight')) { rec.path.classList.add('dim'); } else { diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/modules/leftBar.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/leftBar.ts index f460ed7360e..1d7f1ae6098 100644 --- a/rush-plugins/rush-serve-plugin/src/dashboard/modules/leftBar.ts +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/leftBar.ts @@ -1,10 +1,13 @@ // 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; + sendCommand: (cmd: { + command: string; + operationNames?: string[]; + targetState?: string; + mode?: string; + }) => void; getSelection: () => Set; clearSelectionAndRender: () => void; expandSelectionDependencies: () => void; diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/modules/mainBar.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/mainBar.ts index d321d89b90a..fd05d76114f 100644 --- a/rush-plugins/rush-serve-plugin/src/dashboard/modules/mainBar.ts +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/mainBar.ts @@ -1,15 +1,14 @@ // 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 */ -/* eslint-disable @typescript-eslint/typedef */ - export interface IMainBarActionWiringOptions { connect: () => void; disconnect: () => void; isConnected: () => boolean; - sendCommand: (cmd: any) => void; - getGraphSettings: () => any; + sendCommand: (cmd: { command: string; value?: boolean; parallelism?: number }) => void; + getGraphSettings: () => + | { debugMode?: boolean; verbose?: boolean; pauseNextIteration?: boolean } + | undefined; debugBtn: HTMLElement | undefined; verboseBtn: HTMLElement | undefined; parallelismInput: HTMLInputElement | undefined; @@ -78,13 +77,16 @@ export function wireMainBarActions(options: IMainBarActionWiringOptions): void { if (parallelismInput) { parallelismInput.addEventListener('change', () => { - sendCommand({ command: 'set-parallelism', parallelism: Number(parallelismInput.value) || 1 }); + const value: number = Number(parallelismInput.value) || 1; + sendCommand({ command: 'set-parallelism', parallelism: value }); }); } if (playPauseBtn) { playPauseBtn.addEventListener('click', () => { - const graphSettings = getGraphSettings(); + const graphSettings: + | { debugMode?: boolean; verbose?: boolean; pauseNextIteration?: boolean } + | undefined = getGraphSettings(); if (!graphSettings) return; const next: boolean = !!graphSettings.pauseNextIteration; sendCommand({ command: 'set-pause-next-iteration', value: !next }); diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/modules/phaseLegend.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/phaseLegend.ts index f50b5125bd4..3fe500eb458 100644 --- a/rush-plugins/rush-serve-plugin/src/dashboard/modules/phaseLegend.ts +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/phaseLegend.ts @@ -1,8 +1,6 @@ // 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; @@ -78,7 +76,7 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio continue; } - let rec = byPhase.get(phase); + let rec: { ops: IPhaseProblemOperation[]; statusSet: Set } | undefined = byPhase.get(phase); if (!rec) { rec = { ops: [], statusSet: new Set() }; byPhase.set(phase, rec); @@ -108,7 +106,7 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio if (!chosen) chosen = 'Ready'; - const problemOps = rec.ops.filter( + const problemOps: IPhaseProblemOperation[] = rec.ops.filter( ({ displayStatus }) => displayStatus === 'Failure' || displayStatus === 'SuccessWithWarning' || @@ -125,11 +123,12 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio const renderPhasePane = (): void => { if (!options.phaseGroupsEl) return; - const summaries = computePhaseSummaries(); + const summaries: Array<{ phase: string; status: string; problemOps: IPhaseProblemOperation[] }> = + computePhaseSummaries(); (options.phaseGroupsEl as HTMLElement).innerHTML = ''; if (!summaries.length) { - const empty = document.createElement('div'); + const empty: HTMLDivElement = document.createElement('div'); empty.className = 'phase-pane-empty'; empty.textContent = 'No phases'; options.phaseGroupsEl.appendChild(empty); @@ -137,17 +136,17 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio } for (const summary of summaries) { - const group = document.createElement('div'); + const group: HTMLDivElement = document.createElement('div'); group.className = 'phase-group'; - const header = document.createElement('div'); + const header: HTMLDivElement = document.createElement('div'); header.className = 'phase-header'; - const emoji = document.createElement('span'); + const emoji: HTMLSpanElement = document.createElement('span'); emoji.className = 'phase-status-emoji'; emoji.textContent = options.statusEmoji(summary.status); - const nameSpan = document.createElement('span'); + const nameSpan: HTMLSpanElement = document.createElement('span'); nameSpan.className = 'phase-name'; nameSpan.textContent = summary.phase.replace(/^_phase:/, ''); @@ -156,40 +155,40 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio group.appendChild(header); if (summary.problemOps.length) { - const list = document.createElement('ul'); + const list: HTMLUListElement = 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; + const sortedProblems: IPhaseProblemOperation[] = [...summary.problemOps].sort((a, b) => { + const ai: number = phaseStatusPriorityIndex.get(a.displayStatus) ?? 999; + const bi: number = phaseStatusPriorityIndex.get(b.displayStatus) ?? 999; if (ai !== bi) return ai - bi; - const an = a.op.name.toLowerCase(); - const bn = b.op.name.toLowerCase(); + const an: string = a.op.name.toLowerCase(); + const bn: string = b.op.name.toLowerCase(); if (an < bn) return -1; if (an > bn) return 1; return 0; }); for (const { op, displayStatus } of sortedProblems) { - const item = document.createElement('li'); + const item: HTMLLIElement = document.createElement('li'); - const status = document.createElement('span'); + const status: HTMLSpanElement = document.createElement('span'); status.className = 'phase-problem-emoji'; status.textContent = options.statusEmoji(displayStatus); item.appendChild(status); - const logUrl = + const logUrl: string | undefined = (op.logFileURLs && (op.logFileURLs.text || op.logFileURLs.error || op.logFileURLs.jsonl)) || undefined; if (logUrl) { - const link = document.createElement('a'); + const link: HTMLAnchorElement = document.createElement('a'); link.href = logUrl; link.target = '_blank'; link.rel = 'noopener noreferrer'; link.textContent = op.name; item.appendChild(link); } else { - const span = document.createElement('span'); + const span: HTMLSpanElement = document.createElement('span'); span.textContent = op.name; item.appendChild(span); } @@ -209,10 +208,10 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio if (!legendEl) return; if (!legendEl._initialized) { - const header = document.createElement('h4'); + const header: HTMLHeadingElement = document.createElement('h4'); header.textContent = 'Legend'; - const toggleBtn = document.createElement('button'); + const toggleBtn: HTMLButtonElement = document.createElement('button'); toggleBtn.type = 'button'; toggleBtn.id = 'legend-collapse-btn'; toggleBtn.setAttribute('aria-label', 'Collapse legend'); @@ -238,7 +237,7 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio toggleBtn.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); toggleBtn.addEventListener('click', () => { - const isCollapsed = legendEl.classList.toggle('collapsed'); + const isCollapsed: boolean = legendEl.classList.toggle('collapsed'); window.localStorage.setItem('rushServeLegendCollapsed', isCollapsed ? '1' : '0'); toggleBtn.textContent = isCollapsed ? '+' : 'βˆ’'; toggleBtn.setAttribute('aria-label', isCollapsed ? 'Expand legend' : 'Collapse legend'); @@ -248,13 +247,13 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio } while (legendEl.children.length > 1) { - const lastChild = legendEl.lastChild; + const lastChild: ChildNode | null = legendEl.lastChild; if (!lastChild) break; legendEl.removeChild(lastChild); } if (legendEl.classList.contains('collapsed')) { - const stub = document.createElement('div'); + const stub: HTMLDivElement = document.createElement('div'); stub.style.fontSize = '0.5rem'; stub.style.opacity = '0.7'; stub.textContent = 'Collapsed'; @@ -262,31 +261,31 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio return; } - const statusColors = options.getStatusColors(); + const statusColors: Record = options.getStatusColors(); - const columnsWrap = document.createElement('div'); + const columnsWrap: HTMLDivElement = document.createElement('div'); columnsWrap.className = 'legend-columns'; - const colPrimary = document.createElement('div'); + const colPrimary: HTMLDivElement = document.createElement('div'); colPrimary.className = 'legend-col'; - const primaryHead = document.createElement('div'); + const primaryHead: HTMLDivElement = document.createElement('div'); primaryHead.className = 'legend-heading'; primaryHead.textContent = 'Statuses'; colPrimary.appendChild(primaryHead); for (const status of legendOrder) { - const row = document.createElement('div'); + const row: HTMLDivElement = document.createElement('div'); row.className = 'legend-row'; - const sample = document.createElement('span'); + const sample: HTMLSpanElement = document.createElement('span'); sample.className = 'legend-emoji'; sample.textContent = options.statusEmoji(status); sample.style.borderColor = statusColors[status] || '#4b5563'; - const labelWrap = document.createElement('div'); + const labelWrap: HTMLDivElement = document.createElement('div'); labelWrap.className = 'legend-label-wrap'; - const titleSpan = document.createElement('span'); + const titleSpan: HTMLSpanElement = document.createElement('span'); titleSpan.textContent = options.overallStatusText(status); labelWrap.appendChild(titleSpan); @@ -295,20 +294,20 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio colPrimary.appendChild(row); } - const unknownRow = document.createElement('div'); + const unknownRow: HTMLDivElement = document.createElement('div'); unknownRow.className = 'legend-row'; - const unknownSample = document.createElement('span'); + const unknownSample: HTMLSpanElement = document.createElement('span'); unknownSample.className = 'legend-emoji status-Unknown'; unknownSample.textContent = '❓'; unknownSample.style.borderColor = '#4b5563'; - const unknownLabelWrap = document.createElement('div'); + const unknownLabelWrap: HTMLDivElement = document.createElement('div'); unknownLabelWrap.className = 'legend-label-wrap'; - const unknownTitle = document.createElement('span'); + const unknownTitle: HTMLSpanElement = document.createElement('span'); unknownTitle.textContent = 'UNKNOWN'; - const unknownDetail = document.createElement('small'); + const unknownDetail: HTMLElement = document.createElement('small'); unknownDetail.textContent = 'Never executed'; unknownLabelWrap.appendChild(unknownTitle); @@ -318,27 +317,27 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio unknownRow.appendChild(unknownLabelWrap); colPrimary.appendChild(unknownRow); - const colSecondary = document.createElement('div'); + const colSecondary: HTMLDivElement = document.createElement('div'); colSecondary.className = 'legend-col'; - const secondaryHead = document.createElement('div'); + const secondaryHead: HTMLDivElement = document.createElement('div'); secondaryHead.className = 'legend-heading'; secondaryHead.textContent = 'State Modifiers'; colSecondary.appendChild(secondaryHead); const addModifier = (sampleFactory: () => HTMLElement, label: string, detail?: string): void => { - const row = document.createElement('div'); + const row: HTMLDivElement = document.createElement('div'); row.className = 'legend-row'; - const sample = sampleFactory(); + const sample: HTMLElement = sampleFactory(); - const labelWrap = document.createElement('div'); + const labelWrap: HTMLDivElement = document.createElement('div'); labelWrap.className = 'legend-label-wrap'; - const titleSpan = document.createElement('span'); + const titleSpan: HTMLSpanElement = document.createElement('span'); titleSpan.textContent = label; labelWrap.appendChild(titleSpan); if (detail) { - const small = document.createElement('small'); + const small: HTMLElement = document.createElement('small'); small.textContent = detail; labelWrap.appendChild(small); } @@ -349,7 +348,7 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio }; const makeNodeBox = (borderStyle?: string, boxShadow?: string, borderColor?: string): HTMLElement => { - const sample = document.createElement('span'); + const sample: HTMLSpanElement = document.createElement('span'); sample.className = 'legend-emoji'; if (borderStyle) sample.style.borderStyle = borderStyle; if (borderColor) sample.style.borderColor = borderColor; @@ -362,16 +361,16 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio const makeDotted = (): HTMLElement => makeNodeBox('dotted'); const makeActive = (): HTMLElement => { - const wrap = document.createElement('div'); + const wrap: HTMLDivElement = document.createElement('div'); wrap.className = 'legend-enabled-sample'; - const base = document.createElement('span'); + const base: HTMLSpanElement = document.createElement('span'); base.style.opacity = '0.15'; base.style.fontSize = '11px'; base.textContent = '⬜'; wrap.appendChild(base); - const rocket = document.createElement('span'); + const rocket: HTMLSpanElement = document.createElement('span'); rocket.style.position = 'absolute'; rocket.style.bottom = '0'; rocket.style.left = '0'; @@ -384,16 +383,16 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio }; const makePending = (): HTMLElement => { - const wrap = document.createElement('div'); + const wrap: HTMLDivElement = document.createElement('div'); wrap.className = 'legend-enabled-sample'; - const base = document.createElement('span'); + const base: HTMLSpanElement = document.createElement('span'); base.style.opacity = '0.15'; base.style.fontSize = '11px'; base.textContent = '⬜'; wrap.appendChild(base); - const clock = document.createElement('span'); + const clock: HTMLSpanElement = document.createElement('span'); clock.style.position = 'absolute'; clock.style.top = '0'; clock.style.left = '0'; @@ -406,9 +405,9 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio }; const makeEnabledSample = (emoji: string): HTMLElement => { - const wrap = document.createElement('div'); + const wrap: HTMLDivElement = document.createElement('div'); wrap.className = 'legend-enabled-sample'; - const sub = document.createElement('span'); + const sub: HTMLSpanElement = document.createElement('span'); sub.className = 'sub'; sub.textContent = emoji; wrap.appendChild(sub); @@ -420,7 +419,7 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio addModifier(makeDashed, 'Not in this iteration', 'Excluded this iteration'); addModifier(makeDotted, 'Filtered out', 'Hidden by view/search'); - const enabledHead = document.createElement('div'); + const enabledHead: HTMLDivElement = document.createElement('div'); enabledHead.className = 'legend-subheading'; enabledHead.textContent = 'Enabled States'; colSecondary.appendChild(enabledHead); diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/modules/terminalPane.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/terminalPane.ts index 391b0eea46f..6e127791822 100644 --- a/rush-plugins/rush-serve-plugin/src/dashboard/modules/terminalPane.ts +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/terminalPane.ts @@ -1,8 +1,6 @@ // 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 { AnsiSgrParser } from './ansiSgrParser'; interface ITerminalElementWithState extends HTMLElement { @@ -47,7 +45,7 @@ function _appendChunk( if (!terminalBody) return; const raw: string = String(text || ''); - const segments = ansiParser.process(raw); + const segments: Array<{ text: string; style?: string }> = ansiParser.process(raw); if (segments && segments.length) { for (const seg of segments) { diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/modules/viewBar.ts b/rush-plugins/rush-serve-plugin/src/dashboard/modules/viewBar.ts index fdc844d409a..c574bb70a26 100644 --- a/rush-plugins/rush-serve-plugin/src/dashboard/modules/viewBar.ts +++ b/rush-plugins/rush-serve-plugin/src/dashboard/modules/viewBar.ts @@ -1,8 +1,6 @@ // 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 { @@ -20,7 +18,7 @@ export function wireViewBar(options: IViewBarWiringOptions): void { document.querySelectorAll('input[name="view"]').forEach((radio: Element) => { radio.addEventListener('change', () => { - const input = radio as HTMLInputElement; + const input: HTMLInputElement = radio as HTMLInputElement; if (!input.checked) return; setView(input.value as DashboardView); From 4221a60b88e807cf11d2569174ba59ca739951eb Mon Sep 17 00:00:00 2001 From: Mo Jazayeri Date: Thu, 30 Jul 2026 01:59:09 +0000 Subject: [PATCH 06/12] Move Rush serve dashboard to apps --- apps/rush-serve-dashboard/config/heft.json | 28 +++++++++++++++++++ apps/rush-serve-dashboard/config/rig.json | 6 ++++ apps/rush-serve-dashboard/eslint.config.js | 16 +++++++++++ apps/rush-serve-dashboard/package.json | 19 +++++++++++++ .../rush-serve-dashboard/src}/dashboard.html | 2 +- .../rush-serve-dashboard/src}/dashboard.ts | 0 .../src}/modules/ansiSgrParser.ts | 0 .../src}/modules/dashboardMutations.ts | 0 .../src}/modules/dashboardWebSocket.ts | 0 .../src}/modules/graphFiltering.ts | 0 .../src}/modules/graphSelection.ts | 0 .../src}/modules/graphView.ts | 0 .../src}/modules/leftBar.ts | 0 .../src}/modules/mainBar.ts | 0 .../src}/modules/phaseLegend.ts | 0 .../src}/modules/selectionBar.ts | 0 .../src}/modules/statusHelpers.ts | 0 .../src}/modules/tableView.ts | 0 .../src}/modules/terminalPane.ts | 0 .../src}/modules/topBar.ts | 0 .../src}/modules/urlState.ts | 0 .../src}/modules/viewBar.ts | 0 .../src}/styles/dashboard.css | 0 .../src}/styles/graph-view.css | 0 .../src}/styles/selection-bar.css | 0 .../src}/styles/table-view.css | 0 .../src}/styles/terminal-pane.css | 0 .../src}/styles/top-bar.css | 0 apps/rush-serve-dashboard/tsconfig.json | 3 ++ .../rush/browser-approved-packages.json | 4 +++ .../config/subspaces/default/pnpm-lock.yaml | 23 ++++++++++++--- .../config/subspaces/default/repo-state.json | 2 +- rush-plugins/rush-serve-plugin/README.md | 2 +- .../rush-serve-plugin/config/heft.json | 4 +-- .../rush-serve-plugin/eslint.config.js | 19 ------------- rush-plugins/rush-serve-plugin/package.json | 1 + rush-plugins/rush-serve-plugin/tsconfig.json | 1 - rush.json | 6 ++++ 38 files changed, 107 insertions(+), 29 deletions(-) create mode 100644 apps/rush-serve-dashboard/config/heft.json create mode 100644 apps/rush-serve-dashboard/config/rig.json create mode 100644 apps/rush-serve-dashboard/eslint.config.js create mode 100644 apps/rush-serve-dashboard/package.json rename {rush-plugins/rush-serve-plugin/src/dashboard => apps/rush-serve-dashboard/src}/dashboard.html (99%) rename {rush-plugins/rush-serve-plugin/src/dashboard => apps/rush-serve-dashboard/src}/dashboard.ts (100%) rename {rush-plugins/rush-serve-plugin/src/dashboard => apps/rush-serve-dashboard/src}/modules/ansiSgrParser.ts (100%) rename {rush-plugins/rush-serve-plugin/src/dashboard => apps/rush-serve-dashboard/src}/modules/dashboardMutations.ts (100%) rename {rush-plugins/rush-serve-plugin/src/dashboard => apps/rush-serve-dashboard/src}/modules/dashboardWebSocket.ts (100%) rename {rush-plugins/rush-serve-plugin/src/dashboard => apps/rush-serve-dashboard/src}/modules/graphFiltering.ts (100%) rename {rush-plugins/rush-serve-plugin/src/dashboard => apps/rush-serve-dashboard/src}/modules/graphSelection.ts (100%) rename {rush-plugins/rush-serve-plugin/src/dashboard => apps/rush-serve-dashboard/src}/modules/graphView.ts (100%) rename {rush-plugins/rush-serve-plugin/src/dashboard => apps/rush-serve-dashboard/src}/modules/leftBar.ts (100%) rename {rush-plugins/rush-serve-plugin/src/dashboard => apps/rush-serve-dashboard/src}/modules/mainBar.ts (100%) rename {rush-plugins/rush-serve-plugin/src/dashboard => apps/rush-serve-dashboard/src}/modules/phaseLegend.ts (100%) rename {rush-plugins/rush-serve-plugin/src/dashboard => apps/rush-serve-dashboard/src}/modules/selectionBar.ts (100%) rename {rush-plugins/rush-serve-plugin/src/dashboard => apps/rush-serve-dashboard/src}/modules/statusHelpers.ts (100%) rename {rush-plugins/rush-serve-plugin/src/dashboard => apps/rush-serve-dashboard/src}/modules/tableView.ts (100%) rename {rush-plugins/rush-serve-plugin/src/dashboard => apps/rush-serve-dashboard/src}/modules/terminalPane.ts (100%) rename {rush-plugins/rush-serve-plugin/src/dashboard => apps/rush-serve-dashboard/src}/modules/topBar.ts (100%) rename {rush-plugins/rush-serve-plugin/src/dashboard => apps/rush-serve-dashboard/src}/modules/urlState.ts (100%) rename {rush-plugins/rush-serve-plugin/src/dashboard => apps/rush-serve-dashboard/src}/modules/viewBar.ts (100%) rename {rush-plugins/rush-serve-plugin/src/dashboard => apps/rush-serve-dashboard/src}/styles/dashboard.css (100%) rename {rush-plugins/rush-serve-plugin/src/dashboard => apps/rush-serve-dashboard/src}/styles/graph-view.css (100%) rename {rush-plugins/rush-serve-plugin/src/dashboard => apps/rush-serve-dashboard/src}/styles/selection-bar.css (100%) rename {rush-plugins/rush-serve-plugin/src/dashboard => apps/rush-serve-dashboard/src}/styles/table-view.css (100%) rename {rush-plugins/rush-serve-plugin/src/dashboard => apps/rush-serve-dashboard/src}/styles/terminal-pane.css (100%) rename {rush-plugins/rush-serve-plugin/src/dashboard => apps/rush-serve-dashboard/src}/styles/top-bar.css (100%) create mode 100644 apps/rush-serve-dashboard/tsconfig.json diff --git a/apps/rush-serve-dashboard/config/heft.json b/apps/rush-serve-dashboard/config/heft.json new file mode 100644 index 00000000000..55f215d764e --- /dev/null +++ b/apps/rush-serve-dashboard/config/heft.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft.schema.json", + + "extends": "local-web-rig/profiles/app/config/heft.json", + + "phasesByName": { + "build": { + "tasksByName": { + "copy-dashboard-assets": { + "taskPlugin": { + "pluginPackage": "@rushstack/heft", + "pluginName": "copy-files-plugin", + "options": { + "copyOperations": [ + { + "sourcePath": "src", + "destinationFolders": ["lib-esm"], + "fileExtensions": [".html"], + "hardlink": true + } + ] + } + } + } + } + } + } +} diff --git a/apps/rush-serve-dashboard/config/rig.json b/apps/rush-serve-dashboard/config/rig.json new file mode 100644 index 00000000000..26f617ab3fc --- /dev/null +++ b/apps/rush-serve-dashboard/config/rig.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + + "rigPackageName": "local-web-rig", + "rigProfile": "app" +} diff --git a/apps/rush-serve-dashboard/eslint.config.js b/apps/rush-serve-dashboard/eslint.config.js new file mode 100644 index 00000000000..72fe2a29d25 --- /dev/null +++ b/apps/rush-serve-dashboard/eslint.config.js @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const webAppProfile = require('local-web-rig/profiles/app/includes/eslint/flat/profile/web-app'); + +module.exports = [ + ...webAppProfile, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + tsconfigRootDir: __dirname + } + } + } +]; diff --git a/apps/rush-serve-dashboard/package.json b/apps/rush-serve-dashboard/package.json new file mode 100644 index 00000000000..45813b1b61c --- /dev/null +++ b/apps/rush-serve-dashboard/package.json @@ -0,0 +1,19 @@ +{ + "name": "@rushstack/rush-serve-dashboard", + "version": "0.0.0", + "description": "Web dashboard for the Rush serve WebSocket protocol", + "private": true, + "license": "MIT", + "scripts": { + "build": "heft test --clean", + "start": "heft start", + "_phase:build": "heft run --only build -- --clean", + "_phase:test": "heft run --only test -- --clean" + }, + "devDependencies": { + "@rushstack/heft": "workspace:*", + "eslint": "~9.37.0", + "local-web-rig": "workspace:*" + }, + "sideEffects": false +} diff --git a/rush-plugins/rush-serve-plugin/src/dashboard/dashboard.html b/apps/rush-serve-dashboard/src/dashboard.html similarity index 99% rename from rush-plugins/rush-serve-plugin/src/dashboard/dashboard.html rename to apps/rush-serve-dashboard/src/dashboard.html index f7776bf5238..8f1dfed8923 100644 --- a/rush-plugins/rush-serve-plugin/src/dashboard/dashboard.html +++ b/apps/rush-serve-dashboard/src/dashboard.html @@ -5,7 +5,7 @@ - rush-serve-plugin Demo + Rush Serve Dashboard Date: Thu, 30 Jul 2026 20:14:43 +0000 Subject: [PATCH 07/12] Update repository project index --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8a54e1dea4e..e40ff5073d8 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ These GitHub repositories provide supplementary resources for Rush Stack: | Folder | Description | | ------ | -----------| | [/apps/lockfile-explorer-web](./apps/lockfile-explorer-web/) | Rush Lockfile Explorer: helper project for building the React web application component | +| [/apps/rush-serve-dashboard](./apps/rush-serve-dashboard/) | Web dashboard for the Rush serve WebSocket protocol | | [/build-tests-samples/heft-node-basic-tutorial](./build-tests-samples/heft-node-basic-tutorial/) | (Copy of sample project) Building this project is a regression test for Heft | | [/build-tests-samples/heft-node-jest-tutorial](./build-tests-samples/heft-node-jest-tutorial/) | (Copy of sample project) Building this project is a regression test for Heft | | [/build-tests-samples/heft-node-rig-tutorial](./build-tests-samples/heft-node-rig-tutorial/) | (Copy of sample project) Building this project is a regression test for Heft | From 9eba5fc00de5de15aff568cb8fbf896c885665f3 Mon Sep 17 00:00:00 2001 From: Mo Jazayeri Date: Thu, 30 Jul 2026 22:38:58 +0000 Subject: [PATCH 08/12] Add rush serve dashboard tests and docs --- apps/rush-serve-dashboard/README.md | 15 ++ .../src/test/actionWiring.test.ts | 227 ++++++++++++++++++ .../src/test/dashboard.test.ts | 71 ++++++ .../src/test/dashboardState.test.ts | 182 ++++++++++++++ .../src/test/dashboardWebSocket.test.ts | 100 ++++++++ .../src/test/graphSelection.test.ts | 72 ++++++ .../src/test/graphView.test.ts | 57 +++++ .../src/test/outputPanels.test.ts | 100 ++++++++ .../src/test/tableView.test.ts | 49 ++++ rush-plugins/rush-serve-plugin/README.md | 16 ++ 10 files changed, 889 insertions(+) create mode 100644 apps/rush-serve-dashboard/README.md create mode 100644 apps/rush-serve-dashboard/src/test/actionWiring.test.ts create mode 100644 apps/rush-serve-dashboard/src/test/dashboard.test.ts create mode 100644 apps/rush-serve-dashboard/src/test/dashboardState.test.ts create mode 100644 apps/rush-serve-dashboard/src/test/dashboardWebSocket.test.ts create mode 100644 apps/rush-serve-dashboard/src/test/graphSelection.test.ts create mode 100644 apps/rush-serve-dashboard/src/test/graphView.test.ts create mode 100644 apps/rush-serve-dashboard/src/test/outputPanels.test.ts create mode 100644 apps/rush-serve-dashboard/src/test/tableView.test.ts diff --git a/apps/rush-serve-dashboard/README.md b/apps/rush-serve-dashboard/README.md new file mode 100644 index 00000000000..c77a76fee7d --- /dev/null +++ b/apps/rush-serve-dashboard/README.md @@ -0,0 +1,15 @@ +# @rushstack/rush-serve-dashboard + +This private package provides the browser dashboard for `@rushstack/rush-serve-plugin`. Its emitted ESM +assets are copied into the plugin package during the plugin build; it is not published independently. + +The dashboard displays Rush operation state in table and graph views, exposes controls supported by the +plugin's WebSocket protocol, and shows operation logs. See the +[rush-serve-plugin documentation](../../rush-plugins/rush-serve-plugin/README.md#dashboard) for configuration +and usage. + +## Development + +From this folder, run `rushx start` to start the Heft development server or `rushx build` to build and test the +project. From the repository root, run `rush build --to @rushstack/rush-serve-plugin` to validate the dashboard +and the consuming plugin together. diff --git a/apps/rush-serve-dashboard/src/test/actionWiring.test.ts b/apps/rush-serve-dashboard/src/test/actionWiring.test.ts new file mode 100644 index 00000000000..418179ec7e3 --- /dev/null +++ b/apps/rush-serve-dashboard/src/test/actionWiring.test.ts @@ -0,0 +1,227 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { wireLeftBarActions } from '../modules/leftBar'; +import { wireMainBarActions } from '../modules/mainBar'; +import { createSelectionBarController } from '../modules/selectionBar'; +import { + computeWsUrl, + overallStatusText, + setConnected, + showConnectingStatus, + updateManagerState, + updateStatusPill, + type ITopBarRefs +} from '../modules/topBar'; +import { wireViewBar } from '../modules/viewBar'; + +function click(id: string): void { + document.getElementById(id)?.dispatchEvent(new MouseEvent('click', { bubbles: true })); +} + +describe('action wiring', () => { + beforeEach(() => { + document.body.innerHTML = ''; + window.history.replaceState(null, '', '/dashboard'); + }); + + it('wires selection commands and safe/unsafe enabled-state mode', () => { + document.body.innerHTML = ` + + + + + `; + const sendCommand = jest.fn(); + const clearSelectionAndRender = jest.fn(); + const expandSelectionDependencies = jest.fn(); + const expandSelectionConsumers = jest.fn(); + wireLeftBarActions({ + sendCommand, + getSelection: () => new Set(['build']), + clearSelectionAndRender, + expandSelectionDependencies, + expandSelectionConsumers + }); + + click('invalidate-btn'); + click('selection-mode-btn'); + click('set-enabled-disabled-btn'); + click('expand-deps-btn'); + click('expand-consumers-btn'); + click('clear-selection-btn'); + + expect(sendCommand).toHaveBeenNthCalledWith(1, { command: 'invalidate', operationNames: ['build'] }); + expect(sendCommand).toHaveBeenNthCalledWith(2, { + command: 'set-enabled-states', + operationNames: ['build'], + targetState: 'never', + mode: 'unsafe' + }); + expect(expandSelectionDependencies).toHaveBeenCalledTimes(1); + expect(expandSelectionConsumers).toHaveBeenCalledTimes(1); + expect(clearSelectionAndRender).toHaveBeenCalledTimes(1); + }); + + it('wires manager commands and keyboard selection', () => { + document.body.innerHTML = + ''; + const debugBtn: HTMLButtonElement = document.createElement('button'); + const verboseBtn: HTMLButtonElement = document.createElement('button'); + const parallelismInput: HTMLInputElement = document.createElement('input'); + const playPauseBtn: HTMLButtonElement = document.createElement('button'); + const sendCommand = jest.fn(); + const connect = jest.fn(); + const setSelection = jest.fn(); + const clearSelection = jest.fn(); + const render = jest.fn(); + parallelismInput.value = '4'; + + wireMainBarActions({ + connect, + disconnect: jest.fn(), + isConnected: () => false, + sendCommand, + getGraphSettings: () => ({ debugMode: false, verbose: true, pauseNextIteration: false }), + debugBtn, + verboseBtn, + parallelismInput, + playPauseBtn, + getOperationNames: () => ['build', 'test'], + setSelection, + clearSelection, + hasSelection: () => true, + render + }); + + click('connect-btn'); + click('execute-btn'); + debugBtn.click(); + verboseBtn.click(); + parallelismInput.dispatchEvent(new Event('change')); + playPauseBtn.click(); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', ctrlKey: true })); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + + expect(connect).toHaveBeenCalledTimes(1); + expect(sendCommand.mock.calls.map((call: unknown[]) => call[0])).toEqual([ + { command: 'execute' }, + { command: 'set-debug', value: true }, + { command: 'set-verbose', value: false }, + { command: 'set-parallelism', parallelism: 4 }, + { command: 'set-pause-next-iteration', value: true } + ]); + expect(setSelection).toHaveBeenCalledWith(new Set(['build', 'test'])); + expect(clearSelection).toHaveBeenCalledTimes(1); + expect(render).toHaveBeenCalledTimes(2); + }); + + it('wires view, filter, and search controls', () => { + document.body.innerHTML = ` + + +
`; + let view: 'table' | 'graph' = 'table'; + let filter: 'all' | 'failed-warn' = 'all'; + const markGraphDirty = jest.fn(); + const render = jest.fn(); + const setSearchQuery = jest.fn(); + wireViewBar({ + getView: () => view, + setView: (next) => (view = next), + getFilter: () => filter, + setFilter: (next) => (filter = next), + setSearchQuery, + markGraphDirty, + render + }); + + const graphRadio = document.querySelector('input[value="graph"]') as HTMLInputElement; + graphRadio.checked = true; + graphRadio.dispatchEvent(new Event('change')); + const filterSelect = document.getElementById('filter-select') as HTMLSelectElement; + filterSelect.value = 'failed-warn'; + filterSelect.dispatchEvent(new Event('change')); + const searchInput = document.getElementById('name-search') as HTMLInputElement; + searchInput.value = 'build'; + searchInput.dispatchEvent(new Event('input')); + + expect(document.getElementById('left')?.style.display).toBe('none'); + expect(document.getElementById('right')?.style.display).toBe(''); + expect(setSearchQuery).toHaveBeenCalledWith('build'); + expect(markGraphDirty).toHaveBeenCalledTimes(2); + expect(window.location.search).toContain('view=graph'); + expect(window.location.search).toContain('filter=failed-warn'); + }); +}); + +describe('top and selection bars', () => { + beforeEach(() => { + document.body.innerHTML = ` + + + + + +
+ `; + }); + + function getRefs(): ITopBarRefs { + return { + connectBtn: document.getElementById('connect-btn') ?? undefined, + statusPill: document.getElementById('status-pill') ?? undefined, + statusEmojiEl: document.getElementById('status-emoji') ?? undefined, + debugBtn: document.getElementById('debug-btn') ?? undefined, + verboseBtn: document.getElementById('verbose-btn') ?? undefined, + playPauseBtn: document.getElementById('play-pause-btn') ?? undefined, + parallelismInput: (document.getElementById('parallelism') as HTMLInputElement) ?? undefined, + managerStateEl: document.getElementById('manager-state') ?? undefined + }; + } + + it('formats URLs and updates connection and status state', () => { + const refs: ITopBarRefs = getRefs(); + expect(computeWsUrl({ host: 'example.test', protocol: 'https:' } as Location)).toBe( + 'wss://example.test/ws' + ); + expect(overallStatusText('SuccessWithWarning')).toBe('WARNING'); + + showConnectingStatus(refs.statusPill, refs.statusEmojiEl, () => 'waiting'); + expect(refs.statusPill?.textContent).toBe('CONNECTING'); + const socket: WebSocket = { readyState: WebSocket.OPEN } as WebSocket; + updateStatusPill(refs, socket, { status: 'Success' }, () => 'success'); + expect(refs.statusPill?.classList.contains('status-Success')).toBe(true); + + const updateSelectionUI = jest.fn(); + setConnected(refs, true, updateSelectionUI, ['execute-btn']); + expect(refs.connectBtn?.dataset.state).toBe('connected'); + expect((document.getElementById('execute-btn') as HTMLButtonElement).disabled).toBe(false); + expect(updateSelectionUI).toHaveBeenCalledTimes(1); + }); + + it('updates manager and selection controls', () => { + const refs: ITopBarRefs = getRefs(); + updateManagerState(refs, { + debugMode: true, + verbose: false, + pauseNextIteration: true, + parallelism: 8, + hasScheduledIteration: true + }); + expect(refs.debugBtn?.getAttribute('aria-pressed')).toBe('true'); + expect(refs.playPauseBtn?.title).toBe('Resume automatic iterations'); + expect(refs.parallelismInput?.value).toBe('8'); + expect(document.getElementById('execute-btn')?.classList.contains('queued')).toBe(true); + + const controller = createSelectionBarController({ + getSelection: () => new Set(['build']), + getCurrentView: () => 'graph', + isConnected: () => true + }); + controller.updateSelectionUI(); + expect(document.getElementById('view-heading-text')?.textContent).toBe('Dependency Graph'); + expect(document.getElementById('selection-count')?.textContent).toBe('1 selected'); + expect((document.getElementById('invalidate-btn') as HTMLButtonElement).disabled).toBe(false); + }); +}); diff --git a/apps/rush-serve-dashboard/src/test/dashboard.test.ts b/apps/rush-serve-dashboard/src/test/dashboard.test.ts new file mode 100644 index 00000000000..5731fbecf4b --- /dev/null +++ b/apps/rush-serve-dashboard/src/test/dashboard.test.ts @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const sockets: MockWebSocket[] = []; + +class MockWebSocket extends EventTarget { + public static readonly OPEN: number = 1; + public readyState: number = 0; + + public constructor() { + super(); + sockets.push(this); + } + + public send(): void {} + public close(): void {} +} + +describe('dashboard entrypoint', () => { + const originalWebSocket: typeof WebSocket = globalThis.WebSocket; + + beforeEach(() => { + jest.resetModules(); + sockets.length = 0; + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; + window.history.replaceState(null, '', '/dashboard'); + document.body.innerHTML = ` + +
+ + +
+
+
+ +
+
+
+ + `; + }); + + afterEach(() => { + globalThis.WebSocket = originalWebSocket; + }); + + it('connects and renders a synchronization message', async () => { + const consoleSpy = jest.spyOn(window.console, 'log').mockImplementation(() => {}); + await import('../dashboard'); + const socket: MockWebSocket = sockets[0]; + socket.readyState = MockWebSocket.OPEN; + socket.dispatchEvent(new Event('open')); + socket.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ + event: 'sync', + operations: [{ name: 'package-a (build)', packageName: 'package-a', phaseName: '_phase:build' }], + currentExecutionStates: [{ name: 'package-a (build)', status: 'Success' }], + graphState: { status: 'Success', parallelism: 2 }, + sessionInfo: { actionName: 'start', repositoryIdentifier: 'rushstack' } + }) + }) + ); + + expect(document.title).toBe('start β€” rushstack'); + expect(document.getElementById('table-stats')?.textContent).toBe('1 operations'); + expect(document.querySelector('#operations-table tbody')?.textContent).toContain('package-a'); + expect(document.getElementById('status-pill')?.textContent).toBe('SUCCESS'); + consoleSpy.mockRestore(); + }); +}); diff --git a/apps/rush-serve-dashboard/src/test/dashboardState.test.ts b/apps/rush-serve-dashboard/src/test/dashboardState.test.ts new file mode 100644 index 00000000000..fbf296c0072 --- /dev/null +++ b/apps/rush-serve-dashboard/src/test/dashboardState.test.ts @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { AnsiSgrParser } from '../modules/ansiSgrParser'; +import { + applyExecutionStates, + patchOperationsFromPayload, + setOperationsFromPayload, + setQueuedStates, + toLastExecutionResultsMap +} from '../modules/dashboardMutations'; +import { computeFilterSetsCore, pruneGraphOperations } from '../modules/graphFiltering'; +import { + buildRunPolicyText, + buildTooltip, + computeDisplayStatus, + enabledGlyph, + getStatusColors, + statusEmoji +} from '../modules/statusHelpers'; +import { loadDashboardUrlState, syncDashboardUrlState } from '../modules/urlState'; + +describe(AnsiSgrParser.name, () => { + it('applies styles, preserves state between chunks, and resets it', () => { + const parser: AnsiSgrParser = new AnsiSgrParser(); + + expect(parser.process('plain\u001b[1;31mbold red')).toEqual([ + { text: 'plain', style: '' }, + { text: 'bold red', style: 'color: #a00; font-weight: 700' } + ]); + expect(parser.process(' continued\u001b[0m reset')).toEqual([ + { text: ' continued', style: 'color: #a00; font-weight: 700' }, + { text: ' reset', style: '' } + ]); + }); + + it('supports bright, background, underline, and inverse styles', () => { + const parser: AnsiSgrParser = new AnsiSgrParser(); + + expect(parser.process('\u001b[4;7;96;41mstyled')).toEqual([ + { + text: 'styled', + style: 'color: #55ffff; background-color: #a00; text-decoration: underline; filter: invert(100%)' + } + ]); + }); + + it('leaves malformed escape sequences as text', () => { + expect(new AnsiSgrParser().process('before\u001b[not-sgr')).toEqual([ + { text: 'before\u001b[not-sgr', style: '' } + ]); + }); +}); + +describe('dashboard mutations', () => { + it('replaces, patches, and updates operation state', () => { + const operations: Map = new Map([ + ['old', { name: 'old' }] + ]); + const executionStates: Map = new Map(); + + setOperationsFromPayload(operations, [{ name: 'build', status: 'Ready' }]); + patchOperationsFromPayload(operations, [{ name: 'test', status: 'Waiting' }]); + applyExecutionStates(operations, executionStates, [ + { name: 'build', status: 'Success', isActive: true }, + { name: 'unknown', status: 'Failure' } + ]); + + expect(Array.from(operations.keys())).toEqual(['build', 'test']); + expect(operations.get('build')).toMatchObject({ status: 'Success', isActive: true }); + expect(executionStates.has('unknown')).toBe(true); + }); + + it('replaces queued states and indexes prior results', () => { + const queuedStates: Map = new Map([['old', { name: 'old' }]]); + setQueuedStates(queuedStates, [{ name: 'next', status: 'Queued' }]); + + expect(Array.from(queuedStates.keys())).toEqual(['next']); + expect(toLastExecutionResultsMap([{ name: 'build', status: 'Failure' }]).get('build')?.status).toBe( + 'Failure' + ); + expect(toLastExecutionResultsMap(undefined).size).toBe(0); + }); +}); + +describe('graph filtering', () => { + it('merges execution state before filtering by status and search text', () => { + const operations: Map = new Map([ + ['package-a (build)', { name: 'package-a (build)', status: 'Ready' }], + ['package-b (test)', { name: 'package-b (test)', status: 'Success' }] + ]); + + const result = computeFilterSetsCore({ + operations, + executionStates: new Map([ + ['package-a (build)', { name: 'package-a (build)', status: 'Failure', isActive: true }] + ]), + currentFilter: 'failed-warn', + searchQuery: 'PACKAGE-A', + computeDisplayStatus: (operation) => operation.status || 'Ready' + }); + + expect(result.visibleOperations.map((operation) => operation.name)).toEqual(['package-a (build)']); + expect(result.filteredOutNames).toEqual(new Set(['package-b (test)'])); + expect(operations.get('package-a (build)')).toMatchObject({ status: 'Failure', isActive: true }); + }); + + it('removes no-op nodes and reconnects their dependents', () => { + const result = pruneGraphOperations([ + { name: 'compile', dependencies: [] }, + { name: 'noop', dependencies: ['compile'], noop: true }, + { name: 'test', dependencies: ['noop'] } + ]); + + expect(result).toEqual([ + { name: 'compile', dependencies: [] }, + { name: 'test', dependencies: ['compile'] } + ]); + }); +}); + +describe('status helpers', () => { + it('uses current state, prior results, and defaults in priority order', () => { + expect( + computeDisplayStatus( + { name: 'build', status: 'Ready' }, + new Map([['build', { name: 'build', status: 'Executing' }]]), + new Map() + ) + ).toBe('Executing'); + expect( + computeDisplayStatus( + { name: 'build', runInThisIteration: false }, + new Map(), + new Map([['build', { name: 'build', status: 'Success' }]]) + ) + ).toBe('Success'); + expect(computeDisplayStatus({ name: 'noop', noop: true }, new Map(), new Map())).toBe('NoOp'); + }); + + it('formats status, policy, and tooltip text', () => { + expect(statusEmoji('Failure')).toBe('❌'); + expect(statusEmoji('custom')).toBe('β€’'); + expect(enabledGlyph({ name: 'build', enabled: 'never' })).toBe('πŸ”΄'); + expect(buildRunPolicyText({ name: 'build', enabled: 'ignore-dependency-changes' })).toBe( + 'Ignores dependency changes' + ); + expect(buildTooltip({ name: 'build', isActive: true }, 'Success')).toContain('Has in-memory state'); + }); + + it('reads status colors with fallback variables', () => { + document.documentElement.style.setProperty('--status-ready', '#111111'); + document.documentElement.style.setProperty('--warn', '#222222'); + document.documentElement.style.setProperty('--danger', '#333333'); + + expect(getStatusColors()).toMatchObject({ + Ready: '#111111', + Executing: '#222222', + Failure: '#333333' + }); + }); +}); + +describe('URL state', () => { + it('loads supported values and ignores unsupported values', () => { + expect(loadDashboardUrlState('?view=graph&filter=failed-warn')).toEqual({ + view: 'graph', + filter: 'failed-warn' + }); + expect(loadDashboardUrlState('?view=cards&filter=success')).toEqual({ view: 'table', filter: 'all' }); + }); + + it('updates dashboard parameters while preserving other URL state', () => { + window.history.replaceState(null, '', '/dashboard?custom=value#output'); + + syncDashboardUrlState('graph', 'failed-warn'); + + expect(window.location.pathname + window.location.search + window.location.hash).toBe( + '/dashboard?custom=value&view=graph&filter=failed-warn#output' + ); + }); +}); diff --git a/apps/rush-serve-dashboard/src/test/dashboardWebSocket.test.ts b/apps/rush-serve-dashboard/src/test/dashboardWebSocket.test.ts new file mode 100644 index 00000000000..1d5739e67fb --- /dev/null +++ b/apps/rush-serve-dashboard/src/test/dashboardWebSocket.test.ts @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { createDashboardWebSocketController } from '../modules/dashboardWebSocket'; + +const mockSockets: MockWebSocket[] = []; + +class MockWebSocket extends EventTarget { + public static readonly OPEN: number = 1; + public readonly sent: string[] = []; + public readonly url: string; + public readyState: number = 0; + public closed: boolean = false; + + public constructor(url: string) { + super(); + this.url = url; + mockSockets.push(this); + } + + public send(data: string): void { + this.sent.push(data); + } + + public close(): void { + this.closed = true; + } +} + +describe('dashboard WebSocket controller', () => { + const originalWebSocket: typeof WebSocket = globalThis.WebSocket; + + beforeEach(() => { + mockSockets.length = 0; + jest.useFakeTimers(); + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; + }); + + afterEach(() => { + jest.useRealTimers(); + globalThis.WebSocket = originalWebSocket; + }); + + it('connects, parses messages, sends commands, and reports lifecycle events', () => { + const onConnectedStateChange = jest.fn(); + const onOpen = jest.fn(); + const onParsedMessage = jest.fn(); + const onParseError = jest.fn(); + const controller = createDashboardWebSocketController({ + getUrl: () => 'ws://localhost/ws', + onConnecting: jest.fn(), + onConnectedStateChange, + onOpen, + onClose: jest.fn(), + onError: jest.fn(), + onParsedMessage, + onParseError, + onLog: jest.fn() + }); + + controller.connect(); + const socket: MockWebSocket = mockSockets[0]; + socket.readyState = MockWebSocket.OPEN; + socket.dispatchEvent(new Event('open')); + socket.dispatchEvent(new MessageEvent('message', { data: '{"event":"sync"}' })); + socket.dispatchEvent(new MessageEvent('message', { data: 'invalid' })); + controller.sendCommand({ command: 'execute' }); + + expect(controller.isConnected()).toBe(true); + expect(onConnectedStateChange).toHaveBeenLastCalledWith(true); + expect(onOpen).toHaveBeenCalledTimes(1); + expect(onParsedMessage).toHaveBeenCalledWith({ event: 'sync' }); + expect(onParseError).toHaveBeenCalledTimes(1); + expect(socket.sent).toEqual(['{"command":"execute"}']); + }); + + it('reconnects after an unexpected close but not after a manual disconnect', () => { + const controller = createDashboardWebSocketController({ + getUrl: () => 'ws://localhost/ws', + onConnecting: jest.fn(), + onConnectedStateChange: jest.fn(), + onOpen: jest.fn(), + onClose: jest.fn(), + onError: jest.fn(), + onParsedMessage: jest.fn(), + onParseError: jest.fn(), + onLog: jest.fn() + }); + + controller.connect(); + mockSockets[0].dispatchEvent(new Event('close')); + jest.advanceTimersByTime(4000); + expect(mockSockets).toHaveLength(2); + + controller.disconnect(); + expect(mockSockets[1].closed).toBe(true); + jest.advanceTimersByTime(4000); + expect(mockSockets).toHaveLength(2); + }); +}); diff --git a/apps/rush-serve-dashboard/src/test/graphSelection.test.ts b/apps/rush-serve-dashboard/src/test/graphSelection.test.ts new file mode 100644 index 00000000000..a491bc2289b --- /dev/null +++ b/apps/rush-serve-dashboard/src/test/graphSelection.test.ts @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { createGraphSelectionController } from '../modules/graphSelection'; + +describe('graph selection controller', () => { + it('selects nodes and expands dependencies and consumers transitively', () => { + const graphEl: HTMLDivElement = document.createElement('div'); + let selection: Set = new Set(); + const onSelectionChanged = jest.fn(); + const controller = createGraphSelectionController({ + graphEl, + getCurrentView: () => 'graph', + getSelection: () => selection, + setSelection: (next) => (selection = next), + getOperations: () => + new Map([ + ['compile', { name: 'compile' }], + ['build', { name: 'build', dependencies: ['compile'] }], + ['test', { name: 'test', dependencies: ['build'] }] + ]), + getGraphNodePositions: () => new Map(), + graphNodeWidth: 100, + graphNodeHeight: 40, + onSelectionChanged, + onLiveSelectionChanged: jest.fn() + }); + + controller.singleSelect('test'); + controller.expandSelectionDependencies(); + expect(selection).toEqual(new Set(['test', 'build', 'compile'])); + controller.singleSelect('compile'); + controller.expandSelectionConsumers(); + expect(selection).toEqual(new Set(['compile', 'build', 'test'])); + controller.toggleSelect('build'); + expect(selection).toEqual(new Set(['compile', 'test'])); + expect(onSelectionChanged).toHaveBeenCalledTimes(5); + }); + + it('replaces selection using a marquee over graph nodes', () => { + const graphEl: HTMLDivElement = document.createElement('div'); + document.body.appendChild(graphEl); + graphEl.getBoundingClientRect = () => ({ left: 0, top: 0 }) as DOMRect; + let selection: Set = new Set(['outside']); + const onLiveSelectionChanged = jest.fn(); + const controller = createGraphSelectionController({ + graphEl, + getCurrentView: () => 'graph', + getSelection: () => selection, + setSelection: (next) => (selection = next), + getOperations: () => new Map(), + getGraphNodePositions: () => + new Map([ + ['inside', { x: 10, y: 10 }], + ['outside', { x: 200, y: 200 }] + ]), + graphNodeWidth: 50, + graphNodeHeight: 30, + onSelectionChanged: jest.fn(), + onLiveSelectionChanged + }); + controller.wireGraphMarqueeSelection(); + + graphEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0, clientX: 0, clientY: 0 })); + graphEl.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 100, clientY: 100 })); + window.dispatchEvent(new MouseEvent('mouseup')); + + expect(selection).toEqual(new Set(['inside'])); + expect(onLiveSelectionChanged).toHaveBeenCalled(); + expect(graphEl.querySelector('.graph-marquee')).toBeNull(); + }); +}); diff --git a/apps/rush-serve-dashboard/src/test/graphView.test.ts b/apps/rush-serve-dashboard/src/test/graphView.test.ts new file mode 100644 index 00000000000..f11132ca9ea --- /dev/null +++ b/apps/rush-serve-dashboard/src/test/graphView.test.ts @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { createGraphViewController, graphState } from '../modules/graphView'; + +describe('graph view controller', () => { + it('renders nodes, removes transitive edges, updates indicators, and wires selection', () => { + document.documentElement.style.setProperty('--status-success', '#00aa00'); + document.body.innerHTML = '
'; + const graphEl: HTMLElement = document.getElementById('graph') as HTMLElement; + const edgesSvg: SVGSVGElement = document.getElementById('edges') as unknown as SVGSVGElement; + const operations = new Map([ + ['compile', { name: 'compile', status: 'Success', enabled: 'affected' }], + ['build', { name: 'build', dependencies: ['compile'], status: 'Success', isActive: true }], + ['test', { name: 'test', dependencies: ['compile', 'build'], status: 'Success', enabled: 'never' }] + ]); + const renderPhaseLegend = jest.fn(); + const singleSelect = jest.fn(); + const toggleSelect = jest.fn(); + const controller = createGraphViewController({ + graphEl, + edgesSvg, + getOperations: () => operations, + getExecutionStates: () => new Map(), + getQueuedStates: () => new Map([['test', { name: 'test', runInThisIteration: true }]]), + getSelection: () => new Set(['compile', 'build']), + getFilteredOutNames: () => new Set(), + getSearchFilteredOutNames: () => new Set(), + getLastExecutionResults: () => new Map(), + getComputeDisplayStatus: () => 'Success', + getStatusEmoji: () => 'ok', + getOverallStatusText: (status) => status || '', + renderPhaseLegend, + singleSelect, + toggleSelect + }); + + controller.ensureGraph(); + + expect(graphState.nodeElements.size).toBe(3); + expect(graphState.edgeElements.map(({ from, to }) => `${from}->${to}`).sort()).toEqual([ + 'build->compile', + 'test->build' + ]); + expect(graphState.nodeElements.get('build')?.querySelector('.active-indicator')).not.toBeNull(); + expect(graphState.nodeElements.get('test')?.querySelector('.pending-indicator')).not.toBeNull(); + expect(graphState.nodeElements.get('test')?.querySelector('.enabled-indicator')?.textContent).toBe('πŸ”΄'); + expect(renderPhaseLegend).toHaveBeenCalled(); + + graphState.nodeElements.get('compile')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + graphState.nodeElements + .get('build') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true, ctrlKey: true })); + expect(singleSelect).toHaveBeenCalledWith('compile'); + expect(toggleSelect).toHaveBeenCalledWith('build'); + }); +}); diff --git a/apps/rush-serve-dashboard/src/test/outputPanels.test.ts b/apps/rush-serve-dashboard/src/test/outputPanels.test.ts new file mode 100644 index 00000000000..0ce1e5ad2c8 --- /dev/null +++ b/apps/rush-serve-dashboard/src/test/outputPanels.test.ts @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { createPhaseLegendController } from '../modules/phaseLegend'; +import { createTerminalPaneController } from '../modules/terminalPane'; + +describe('phase and legend controller', () => { + beforeEach(() => { + window.localStorage.clear(); + document.body.innerHTML = '
'; + }); + + it('summarizes visible phases by priority and links operation logs', () => { + const operations = new Map([ + ['build-a', { name: 'build-a', phaseName: '_phase:build', logFileURLs: { text: '/logs/build-a.log' } }], + ['build-b', { name: 'build-b', phaseName: '_phase:build' }], + ['hidden', { name: 'hidden', phaseName: '_phase:test' }], + ['executing', { name: 'executing', phaseName: '_phase:test' }] + ]); + const statuses: Record = { + 'build-a': 'Failure', + 'build-b': 'Success', + hidden: 'Success', + executing: 'Executing' + }; + const controller = createPhaseLegendController({ + phaseGroupsEl: document.getElementById('phases') ?? undefined, + legendEl: document.getElementById('legend') ?? undefined, + getOperations: () => operations, + getGraphVisibleNames: () => new Set(['build-a', 'build-b']), + computeDisplayStatus: (operation) => statuses[operation.name], + statusEmoji: (status) => status, + overallStatusText: (status) => status || '', + getStatusColors: () => ({ Failure: '#ff0000' }) + }); + + controller.renderAll(); + + const phases: HTMLElement = document.getElementById('phases') as HTMLElement; + expect(phases.textContent).toContain('build'); + expect(phases.textContent).toContain('Failure'); + expect(phases.textContent).toContain('executing'); + expect(phases.textContent).not.toContain('hidden'); + const logLink: HTMLAnchorElement = phases.querySelector('a') as HTMLAnchorElement; + expect(logLink.getAttribute('href')).toBe('/logs/build-a.log'); + expect(logLink.rel).toBe('noopener noreferrer'); + + const collapseButton: HTMLButtonElement = document.getElementById( + 'legend-collapse-btn' + ) as HTMLButtonElement; + collapseButton.click(); + expect(document.getElementById('legend')?.classList.contains('collapsed')).toBe(true); + expect(window.localStorage.getItem('rushServeLegendCollapsed')).toBe('1'); + }); +}); + +describe('terminal pane controller', () => { + beforeEach(() => { + jest.useFakeTimers(); + document.body.innerHTML = ` + + +
`; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('renders ANSI chunks and wires clear, auto-scroll, and visibility controls', () => { + const terminalEl: HTMLElement = document.getElementById('terminal') as HTMLElement; + const terminalBody: HTMLElement = document.getElementById('body') as HTMLElement; + terminalEl.getBoundingClientRect = () => ({ width: 320 }) as DOMRect; + const controller = createTerminalPaneController({ + terminalEl, + terminalBody, + termClearBtn: document.getElementById('clear') ?? undefined, + termAutoScrollCheckbox: document.getElementById('autoscroll-checkbox') as HTMLInputElement, + termAutoscrollBtn: document.getElementById('autoscroll') ?? undefined, + toggleTerminalBtn: document.getElementById('toggle') ?? undefined, + resizerEl: document.getElementById('resizer') ?? undefined + }); + + controller.appendChunk('stderr', '\u001b[31mfailed'); + const chunk: HTMLElement = terminalBody.querySelector('.term-chunk') as HTMLElement; + expect(chunk.textContent).toBe('failed'); + expect(chunk.classList.contains('stderr')).toBe(true); + expect(chunk.style.color).toBe('rgb(170, 0, 0)'); + expect(terminalEl.classList.contains('term-flash')).toBe(true); + jest.advanceTimersByTime(350); + expect(terminalEl.classList.contains('term-flash')).toBe(false); + + document.getElementById('autoscroll')?.click(); + expect((document.getElementById('autoscroll-checkbox') as HTMLInputElement).checked).toBe(false); + document.getElementById('toggle')?.click(); + expect(terminalEl.classList.contains('hidden')).toBe(true); + document.getElementById('clear')?.click(); + expect(terminalBody.textContent).toBe(''); + }); +}); diff --git a/apps/rush-serve-dashboard/src/test/tableView.test.ts b/apps/rush-serve-dashboard/src/test/tableView.test.ts new file mode 100644 index 00000000000..7731211f735 --- /dev/null +++ b/apps/rush-serve-dashboard/src/test/tableView.test.ts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { createTableViewController } from '../modules/tableView'; + +describe('table view controller', () => { + it('renders a package/phase pivot safely and supports cell and group selection', () => { + document.body.innerHTML = '
'; + const operations = new Map([ + ['a-build', { name: 'a-build', packageName: '', phaseName: '_phase:build', isActive: true }], + ['a-test', { name: 'a-test', packageName: '', phaseName: '_phase:test' }], + ['b-build', { name: 'b-build', packageName: 'package-b', phaseName: '_phase:build' }] + ]); + let selection: Set = new Set(); + const onSelectionMutated = jest.fn(); + const controller = createTableViewController({ + tableHead: document.querySelector('thead') ?? undefined, + tableBody: document.querySelector('tbody') ?? undefined, + tableStats: document.getElementById('stats') ?? undefined, + getOperations: () => operations, + getFilteredOperations: () => Array.from(operations.values()), + getSelection: () => selection, + setSelection: (next) => (selection = next), + onSelectionMutated, + computeDisplayStatus: () => 'Success', + enabledGlyph: () => 'enabled', + buildRunPolicyText: () => 'Run if affected', + buildTooltip: (operation) => operation.name, + statusEmoji: () => 'ok', + overallStatusText: (status) => status || '' + }); + + controller.renderTable(); + + expect(document.querySelector('thead')?.textContent).toContain('build'); + expect(document.querySelector('tbody')?.textContent).toContain(''); + expect(document.querySelector('tbody')?.innerHTML).not.toContain(''); + expect(document.getElementById('stats')?.textContent).toBe('3 operations'); + + const firstOperationCell = document.querySelector('td.pivot-cell[title="a-build"]') as HTMLElement; + firstOperationCell.click(); + expect(selection).toEqual(new Set(['a-build'])); + + const firstPackageCell = document.querySelector('.pkg-cell') as HTMLElement; + firstPackageCell.click(); + expect(selection).toEqual(new Set(['a-build', 'a-test'])); + expect(onSelectionMutated).toHaveBeenCalledTimes(2); + }); +}); diff --git a/rush-plugins/rush-serve-plugin/README.md b/rush-plugins/rush-serve-plugin/README.md index 17474b005f8..b556ce248c8 100644 --- a/rush-plugins/rush-serve-plugin/README.md +++ b/rush-plugins/rush-serve-plugin/README.md @@ -43,6 +43,22 @@ The Rush server resolves extensionless module requests to emitted `.js` files. Then open `https://localhost:/dashboard/dashboard.html`. +### Dashboard + +The dashboard provides table and dependency-graph views of Rush operations. Use search or the +failed/warning filter to narrow the current view, select operations to include their dependencies or consumers, +and open the terminal pane to inspect operation logs. The current view and filter are reflected in the `view` +and `filter` URL parameters so a dashboard view can be bookmarked. + +When connected, the dashboard can execute or abort a build, pause the watcher, change parallelism, invalidate +operations, close runners, and change operation enabled states. Enabled-state changes default to safe mode; +unsafe mode enables changes that can leave downstream state inconsistent. The dashboard reconnects +automatically after an unexpected disconnect. A manual disconnect remains disconnected until the Connect +button is selected. + +Because the WebSocket protocol accepts build-control commands, do not expose the dashboard or socket endpoint +to untrusted networks. Apply the same authentication and network restrictions used for the served project. + To use the socket: ```ts From 02a80b595fe96924d47cfc6f41a610d99814cf93 Mon Sep 17 00:00:00 2001 From: Mo Jazayeri Date: Fri, 31 Jul 2026 18:39:04 +0000 Subject: [PATCH 09/12] Exclude dashboard tests from plugin assets --- rush-plugins/rush-serve-plugin/config/heft.json | 1 + 1 file changed, 1 insertion(+) diff --git a/rush-plugins/rush-serve-plugin/config/heft.json b/rush-plugins/rush-serve-plugin/config/heft.json index 2e705f5c853..6f8c256114d 100644 --- a/rush-plugins/rush-serve-plugin/config/heft.json +++ b/rush-plugins/rush-serve-plugin/config/heft.json @@ -31,6 +31,7 @@ "sourcePath": "node_modules/@rushstack/rush-serve-dashboard/lib-esm", "destinationFolders": ["lib-commonjs/dashboard", "lib-esm/dashboard"], "fileExtensions": [".js", ".js.map", ".html", ".css"], + "excludeGlobs": ["test/**"], "hardlink": true } ] From 92027d7be0c0fc2ed7faf4307ecf6b0342f355e1 Mon Sep 17 00:00:00 2001 From: Mo Jazayeri Date: Mon, 3 Aug 2026 23:36:30 +0000 Subject: [PATCH 10/12] Bundle Rush serve dashboard assets --- apps/rush-serve-dashboard/README.md | 11 +-- apps/rush-serve-dashboard/src/dashboard.html | 7 -- apps/rush-serve-dashboard/src/dashboard.ts | 5 +- .../src/modules/graphView.ts | 28 +++--- .../src/styles/graph-view.css | 2 + .../src/test/actionWiring.test.ts | 39 --------- .../src/test/graphView.test.ts | 15 +++- .../src/test/viewBar.test.ts | 79 +++++++++++++++++ .../config/subspaces/default/pnpm-lock.yaml | 86 ++++++++++--------- .../config/subspaces/default/repo-state.json | 2 +- rush-plugins/rush-serve-plugin/README.md | 11 +-- .../rush-serve-plugin/config/heft.json | 17 +--- rush-plugins/rush-serve-plugin/package.json | 5 +- .../src/phasedCommandHandler.ts | 1 - .../rush-serve-plugin/webpack.config.js | 49 +++++++++++ 15 files changed, 221 insertions(+), 136 deletions(-) create mode 100644 apps/rush-serve-dashboard/src/test/viewBar.test.ts create mode 100644 rush-plugins/rush-serve-plugin/webpack.config.js diff --git a/apps/rush-serve-dashboard/README.md b/apps/rush-serve-dashboard/README.md index c77a76fee7d..a88f225ef85 100644 --- a/apps/rush-serve-dashboard/README.md +++ b/apps/rush-serve-dashboard/README.md @@ -1,15 +1,10 @@ # @rushstack/rush-serve-dashboard -This private package provides the browser dashboard for `@rushstack/rush-serve-plugin`. Its emitted ESM -assets are copied into the plugin package during the plugin build; it is not published independently. +This private package provides the browser dashboard for `@rushstack/rush-serve-plugin`. Its emitted ESM assets are copied into the plugin package during the plugin build; it is not published independently. -The dashboard displays Rush operation state in table and graph views, exposes controls supported by the -plugin's WebSocket protocol, and shows operation logs. See the -[rush-serve-plugin documentation](../../rush-plugins/rush-serve-plugin/README.md#dashboard) for configuration +The dashboard displays Rush operation state in table and graph views, exposes controls supported by the plugin's WebSocket protocol, and shows operation logs. See the [rush-serve-plugin documentation](../../rush-plugins/rush-serve-plugin/README.md#dashboard) for configuration and usage. ## Development -From this folder, run `rushx start` to start the Heft development server or `rushx build` to build and test the -project. From the repository root, run `rush build --to @rushstack/rush-serve-plugin` to validate the dashboard -and the consuming plugin together. +From this folder, run `rushx start` to start the Heft development server or `rushx build` to build and test the project. From the repository root, run `rush build --to @rushstack/rush-serve-plugin` to validate the dashboard and the consuming plugin together. diff --git a/apps/rush-serve-dashboard/src/dashboard.html b/apps/rush-serve-dashboard/src/dashboard.html index 8f1dfed8923..cdb2129a414 100644 --- a/apps/rush-serve-dashboard/src/dashboard.html +++ b/apps/rush-serve-dashboard/src/dashboard.html @@ -13,12 +13,6 @@ href="https://microsoft.github.io/vscode-codicons/dist/codicon.css" crossorigin="anonymous" /> - - - - - -
@@ -248,6 +242,5 @@

Phases

- diff --git a/apps/rush-serve-dashboard/src/dashboard.ts b/apps/rush-serve-dashboard/src/dashboard.ts index 0c2e6510824..31a1d3aaf25 100644 --- a/apps/rush-serve-dashboard/src/dashboard.ts +++ b/apps/rush-serve-dashboard/src/dashboard.ts @@ -110,7 +110,10 @@ const tableHead: HTMLTableSectionElement | undefined = tableEl.querySelector('th const tableBody: HTMLTableSectionElement | undefined = tableEl.querySelector('tbody') ?? undefined; const tableStats: HTMLElement | undefined = document.getElementById('table-stats') ?? undefined; const managerStateEl: HTMLElement | undefined = document.getElementById('graph-state') ?? undefined; -const edgesSvg: SVGSVGElement = document.getElementById('edges') as unknown as SVGSVGElement; +const edgesSvg: SVGSVGElement | undefined = document.querySelector('svg#edges') ?? undefined; +if (!edgesSvg) { + throw new Error('The graph edges SVG element was not found.'); +} const graphEl: HTMLElement = document.getElementById('graph') as HTMLElement; const legendEl: HTMLElement | undefined = document.getElementById('graph-legend') ?? undefined; const phasePaneEl: HTMLElement | undefined = document.getElementById('phase-pane') ?? undefined; diff --git a/apps/rush-serve-dashboard/src/modules/graphView.ts b/apps/rush-serve-dashboard/src/modules/graphView.ts index 47bd7d602d9..b3d21e2c5d5 100644 --- a/apps/rush-serve-dashboard/src/modules/graphView.ts +++ b/apps/rush-serve-dashboard/src/modules/graphView.ts @@ -44,7 +44,7 @@ export interface IGraphEdgeRecord { export interface IGraphState { nodePositions: Map; nodeStatus: Map; - nodeElements: Map; + nodeElements: Map; edgeElements: IGraphEdgeRecord[]; } @@ -59,7 +59,7 @@ export const GRAPH_BASE_Y: number = 16; export const graphState: IGraphState = { nodePositions: new Map(), nodeStatus: new Map(), - nodeElements: new Map(), + nodeElements: new Map(), edgeElements: [] }; @@ -269,26 +269,28 @@ export function createGraphViewController(options: IGraphViewControllerOptions): if (!op) return; const x: number = GRAPH_BASE_X + index * (GRAPH_COL_WIDTH + GRAPH_NODE_GAP); const y: number = GRAPH_BASE_Y + levelIndexFromTop * GRAPH_LEVEL_GAP; - const div: HTMLDivElement = document.createElement('div'); - div.className = 'op-node'; - div.dataset.name = name; - div.style.transform = `translate(${x}px, ${y}px)`; + const button: HTMLButtonElement = document.createElement('button'); + button.type = 'button'; + button.setAttribute('aria-label', name); + button.className = 'op-node'; + button.dataset.name = name; + button.style.transform = `translate(${x}px, ${y}px)`; const emojiSpan: HTMLSpanElement = document.createElement('span'); emojiSpan.className = 'emoji'; emojiSpan.textContent = options.getStatusEmoji(options.getComputeDisplayStatus(op)); - div.appendChild(emojiSpan); + button.appendChild(emojiSpan); const enabledSup: HTMLSpanElement = document.createElement('span'); enabledSup.className = 'enabled-indicator'; enabledSup.textContent = ''; - div.appendChild(enabledSup); - div.addEventListener('click', (e) => { + button.appendChild(enabledSup); + button.addEventListener('click', (e) => { e.stopPropagation(); if (e.metaKey || e.ctrlKey) options.toggleSelect(name); else options.singleSelect(name); }); - options.graphEl.appendChild(div); + options.graphEl.appendChild(button); graphState.nodePositions.set(name, { x, y }); - graphState.nodeElements.set(name, div); + graphState.nodeElements.set(name, button); }); }); @@ -406,7 +408,9 @@ export function createGraphViewController(options: IGraphViewControllerOptions): 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'); + const isSelected: boolean = options.getSelection().has(name); + div.setAttribute('aria-pressed', String(isSelected)); + if (isSelected) div.classList.add('selected'); else div.classList.remove('selected'); let activeSpan: HTMLSpanElement | null = div.querySelector( '.active-indicator' diff --git a/apps/rush-serve-dashboard/src/styles/graph-view.css b/apps/rush-serve-dashboard/src/styles/graph-view.css index 693f0796556..6fae97bf70a 100644 --- a/apps/rush-serve-dashboard/src/styles/graph-view.css +++ b/apps/rush-serve-dashboard/src/styles/graph-view.css @@ -288,7 +288,9 @@ display: flex; align-items: center; justify-content: center; + padding: 0; font-size: 14px; + font-family: inherit; line-height: 1; border: 2px solid var(--border); border-radius: 6px; diff --git a/apps/rush-serve-dashboard/src/test/actionWiring.test.ts b/apps/rush-serve-dashboard/src/test/actionWiring.test.ts index 418179ec7e3..024cebe796e 100644 --- a/apps/rush-serve-dashboard/src/test/actionWiring.test.ts +++ b/apps/rush-serve-dashboard/src/test/actionWiring.test.ts @@ -13,7 +13,6 @@ import { updateStatusPill, type ITopBarRefs } from '../modules/topBar'; -import { wireViewBar } from '../modules/viewBar'; function click(id: string): void { document.getElementById(id)?.dispatchEvent(new MouseEvent('click', { bubbles: true })); @@ -115,44 +114,6 @@ describe('action wiring', () => { expect(clearSelection).toHaveBeenCalledTimes(1); expect(render).toHaveBeenCalledTimes(2); }); - - it('wires view, filter, and search controls', () => { - document.body.innerHTML = ` - - -
`; - let view: 'table' | 'graph' = 'table'; - let filter: 'all' | 'failed-warn' = 'all'; - const markGraphDirty = jest.fn(); - const render = jest.fn(); - const setSearchQuery = jest.fn(); - wireViewBar({ - getView: () => view, - setView: (next) => (view = next), - getFilter: () => filter, - setFilter: (next) => (filter = next), - setSearchQuery, - markGraphDirty, - render - }); - - const graphRadio = document.querySelector('input[value="graph"]') as HTMLInputElement; - graphRadio.checked = true; - graphRadio.dispatchEvent(new Event('change')); - const filterSelect = document.getElementById('filter-select') as HTMLSelectElement; - filterSelect.value = 'failed-warn'; - filterSelect.dispatchEvent(new Event('change')); - const searchInput = document.getElementById('name-search') as HTMLInputElement; - searchInput.value = 'build'; - searchInput.dispatchEvent(new Event('input')); - - expect(document.getElementById('left')?.style.display).toBe('none'); - expect(document.getElementById('right')?.style.display).toBe(''); - expect(setSearchQuery).toHaveBeenCalledWith('build'); - expect(markGraphDirty).toHaveBeenCalledTimes(2); - expect(window.location.search).toContain('view=graph'); - expect(window.location.search).toContain('filter=failed-warn'); - }); }); describe('top and selection bars', () => { diff --git a/apps/rush-serve-dashboard/src/test/graphView.test.ts b/apps/rush-serve-dashboard/src/test/graphView.test.ts index f11132ca9ea..12282ffd341 100644 --- a/apps/rush-serve-dashboard/src/test/graphView.test.ts +++ b/apps/rush-serve-dashboard/src/test/graphView.test.ts @@ -8,7 +8,11 @@ describe('graph view controller', () => { document.documentElement.style.setProperty('--status-success', '#00aa00'); document.body.innerHTML = '
'; const graphEl: HTMLElement = document.getElementById('graph') as HTMLElement; - const edgesSvg: SVGSVGElement = document.getElementById('edges') as unknown as SVGSVGElement; + const edgesSvg: SVGSVGElement | undefined = + document.querySelector('svg#edges') ?? undefined; + if (!edgesSvg) { + throw new Error('The graph edges SVG test fixture was not found.'); + } const operations = new Map([ ['compile', { name: 'compile', status: 'Success', enabled: 'affected' }], ['build', { name: 'build', dependencies: ['compile'], status: 'Success', isActive: true }], @@ -47,7 +51,14 @@ describe('graph view controller', () => { expect(graphState.nodeElements.get('test')?.querySelector('.enabled-indicator')?.textContent).toBe('πŸ”΄'); expect(renderPhaseLegend).toHaveBeenCalled(); - graphState.nodeElements.get('compile')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + const compileNode: HTMLButtonElement | undefined = graphState.nodeElements.get('compile'); + expect(compileNode).toMatchObject({ type: 'button', tabIndex: 0 }); + expect(compileNode?.getAttribute('aria-label')).toBe('compile'); + expect(compileNode?.getAttribute('aria-pressed')).toBe('true'); + compileNode?.focus(); + expect(document.activeElement).toBe(compileNode); + + compileNode?.dispatchEvent(new MouseEvent('click', { bubbles: true })); graphState.nodeElements .get('build') ?.dispatchEvent(new MouseEvent('click', { bubbles: true, ctrlKey: true })); diff --git a/apps/rush-serve-dashboard/src/test/viewBar.test.ts b/apps/rush-serve-dashboard/src/test/viewBar.test.ts new file mode 100644 index 00000000000..3283f60dd51 --- /dev/null +++ b/apps/rush-serve-dashboard/src/test/viewBar.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { wireViewBar } from '../modules/viewBar'; +import type { DashboardFilter, DashboardView } from '../modules/urlState'; + +describe(wireViewBar.name, () => { + beforeEach(() => { + document.body.innerHTML = ` + + + + +
+ `; + window.history.replaceState(null, '', '/dashboard'); + }); + + it('initializes controls, panel visibility, and URL state', () => { + wireViewBar({ + getView: () => 'graph', + setView: jest.fn(), + getFilter: () => 'failed-warn', + setFilter: jest.fn(), + setSearchQuery: jest.fn(), + markGraphDirty: jest.fn(), + render: jest.fn() + }); + + expect(document.querySelector('input[value="graph"]')?.checked).toBe(true); + expect(document.querySelector('#filter-select')?.value).toBe('failed-warn'); + expect(document.getElementById('left')?.style.display).toBe('none'); + expect(document.getElementById('right')?.style.display).toBe(''); + expect(window.location.search).toBe('?view=graph&filter=failed-warn'); + }); + + it('updates state and renders when view, filter, and search controls change', () => { + let view: DashboardView = 'table'; + let filter: DashboardFilter = 'all'; + const setSearchQuery = jest.fn(); + const markGraphDirty = jest.fn(); + const render = jest.fn(); + wireViewBar({ + getView: () => view, + setView: (next: DashboardView) => (view = next), + getFilter: () => filter, + setFilter: (next: DashboardFilter) => (filter = next), + setSearchQuery, + markGraphDirty, + render + }); + + const graphRadio: HTMLInputElement = document.querySelector( + 'input[value="graph"]' + ) as HTMLInputElement; + graphRadio.checked = true; + graphRadio.dispatchEvent(new Event('change')); + + const filterSelect: HTMLSelectElement = document.getElementById('filter-select') as HTMLSelectElement; + filterSelect.value = 'failed-warn'; + filterSelect.dispatchEvent(new Event('change')); + + const searchInput: HTMLInputElement = document.getElementById('name-search') as HTMLInputElement; + searchInput.value = 'build'; + searchInput.dispatchEvent(new Event('input')); + + expect(view).toBe('graph'); + expect(filter).toBe('failed-warn'); + expect(setSearchQuery).toHaveBeenCalledWith('build'); + expect(markGraphDirty).toHaveBeenCalledTimes(2); + expect(render).toHaveBeenCalledTimes(3); + expect(document.getElementById('left')?.style.display).toBe('none'); + expect(document.getElementById('right')?.style.display).toBe(''); + expect(window.location.search).toBe('?view=graph&filter=failed-warn'); + }); +}); diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 34385bea698..ebb3fb31a5f 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -3209,7 +3209,7 @@ importers: dependencies: '@jest/core': specifier: ~30.3.0 - version: 30.3.0(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)) + version: 30.3.0 '@jest/reporters': specifier: ~30.3.0 version: 30.3.0 @@ -3227,7 +3227,7 @@ importers: version: link:../../libraries/terminal jest-config: specifier: ~30.3.0 - version: 30.3.0(@types/node@20.17.19)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)) + version: 30.3.0(@types/node@20.17.19) jest-resolve: specifier: ~30.3.0 version: 30.3.0 @@ -3642,7 +3642,7 @@ importers: version: 2.4.0 webpack-dev-server: specifier: ^5.1.0 - version: 5.2.3(@types/webpack@4.41.32)(webpack@5.105.4) + version: 5.2.3(webpack@5.105.4) devDependencies: '@rushstack/heft': specifier: workspace:* @@ -4595,7 +4595,7 @@ importers: version: 1.2.22(@types/node@20.17.19) '@rushstack/heft-node-rig': specifier: 2.11.45 - version: 2.11.45(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0) + version: 2.11.45(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19)(jest-environment-jsdom@30.3.0) '@types/jest': specifier: 30.0.0 version: 30.0.0 @@ -4786,7 +4786,7 @@ importers: version: 5.8.2 url-loader: specifier: ~4.1.1 - version: 4.1.1(file-loader@6.2.0(webpack@5.105.4))(webpack@5.105.4) + version: 4.1.1(webpack@5.105.4) webpack: specifier: ~5.105.2 version: 5.105.4 @@ -5186,6 +5186,9 @@ importers: '@rushstack/heft': specifier: workspace:* version: link:../../apps/heft + '@rushstack/heft-webpack5-plugin': + specifier: workspace:* + version: link:../../heft-plugins/heft-webpack5-plugin '@rushstack/rush-serve-dashboard': specifier: workspace:* version: link:../../apps/rush-serve-dashboard @@ -5204,9 +5207,15 @@ importers: eslint: specifier: ~9.37.0 version: 9.37.0 + html-webpack-plugin: + specifier: ~5.5.0 + version: 5.5.4(webpack@5.105.4) local-node-rig: specifier: workspace:* version: link:../../rigs/local-node-rig + webpack: + specifier: ~5.105.2 + version: 5.105.4 ../../../vscode-extensions/debug-certificate-manager-vscode-extension: dependencies: @@ -22907,7 +22916,7 @@ snapshots: - supports-color - ts-node - '@jest/core@30.3.0(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0))': + '@jest/core@30.3.0': dependencies: '@jest/console': 30.3.0 '@jest/pattern': 30.0.1 @@ -22922,7 +22931,7 @@ snapshots: exit-x: 0.2.2 graceful-fs: 4.2.11 jest-changed-files: 30.3.0 - jest-config: 30.3.0(@types/node@22.9.3)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)) + jest-config: 30.3.0(@types/node@22.9.3) jest-haste-map: 30.3.0 jest-message-util: 30.3.0 jest-regex-util: 30.0.1 @@ -24657,16 +24666,16 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@rushstack/heft-jest-plugin@2.0.12(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/jest@30.0.0)(@types/node@20.17.19)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)(jest-environment-node@30.3.0)': + '@rushstack/heft-jest-plugin@2.0.12(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/jest@30.0.0)(@types/node@20.17.19)(jest-environment-jsdom@30.3.0)(jest-environment-node@30.3.0)': dependencies: - '@jest/core': 30.3.0(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)) + '@jest/core': 30.3.0 '@jest/reporters': 30.3.0 '@jest/transform': 30.3.0 '@rushstack/heft': 1.2.22(@types/node@20.17.19) '@rushstack/heft-config-file': 0.20.12(@types/node@20.17.19) '@rushstack/node-core-library': 5.23.3(@types/node@20.17.19) '@rushstack/terminal': 0.24.2(@types/node@20.17.19) - jest-config: 30.3.0(@types/node@20.17.19)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)) + jest-config: 30.3.0(@types/node@20.17.19) jest-resolve: 30.3.0 jest-snapshot: 30.3.0 optionalDependencies: @@ -24690,13 +24699,13 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@rushstack/heft-node-rig@2.11.45(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)': + '@rushstack/heft-node-rig@2.11.45(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19)(jest-environment-jsdom@30.3.0)': dependencies: '@microsoft/api-extractor': 7.58.12(@types/node@20.17.19) '@rushstack/eslint-config': 4.6.4(eslint@9.37.0)(typescript@5.8.2) '@rushstack/heft': 1.2.22(@types/node@20.17.19) '@rushstack/heft-api-extractor-plugin': 1.3.22(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19) - '@rushstack/heft-jest-plugin': 2.0.12(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/jest@30.0.0)(@types/node@20.17.19)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)(jest-environment-node@30.3.0) + '@rushstack/heft-jest-plugin': 2.0.12(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/jest@30.0.0)(@types/node@20.17.19)(jest-environment-jsdom@30.3.0)(jest-environment-node@30.3.0) '@rushstack/heft-lint-plugin': 1.2.22(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19) '@rushstack/heft-typescript-plugin': 1.3.17(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19) '@types/jest': 30.0.0 @@ -29703,6 +29712,8 @@ snapshots: dedent@0.7.0: {} + dedent@1.7.2: {} + dedent@1.7.2(babel-plugin-macros@3.1.0): optionalDependencies: babel-plugin-macros: 3.1.0 @@ -30247,14 +30258,6 @@ snapshots: transitivePeerDependencies: - supports-color - esbuild-register@3.6.0(esbuild@0.28.0): - dependencies: - debug: 4.4.3(supports-color@8.1.1) - esbuild: 0.28.0 - transitivePeerDependencies: - - supports-color - optional: true - esbuild-runner@2.2.2(esbuild@0.14.54): dependencies: esbuild: 0.14.54 @@ -30826,7 +30829,7 @@ snapshots: eslint@8.57.1: dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.37.0) '@eslint-community/regexpp': 4.12.2 '@eslint/eslintrc': 2.1.4 '@eslint/js': 8.57.1 @@ -31416,13 +31419,6 @@ snapshots: schema-utils: 3.3.0 webpack: 4.47.0 - file-loader@6.2.0(webpack@5.105.4): - dependencies: - loader-utils: 2.0.4 - schema-utils: 3.3.0 - webpack: 5.105.4 - optional: true - file-system-cache@1.1.0: dependencies: fs-extra: 10.1.0 @@ -32810,7 +32806,7 @@ snapshots: - babel-plugin-macros - supports-color - jest-circus@30.3.0(babel-plugin-macros@3.1.0): + jest-circus@30.3.0: dependencies: '@jest/environment': 30.3.0 '@jest/expect': 30.3.0 @@ -32819,7 +32815,7 @@ snapshots: '@types/node': 22.9.3 chalk: 4.1.2 co: 4.6.0 - dedent: 1.7.2(babel-plugin-macros@3.1.0) + dedent: 1.7.2 is-generator-fn: 2.1.0 jest-each: 30.3.0 jest-matcher-utils: 30.3.0 @@ -32915,7 +32911,7 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@30.3.0(@types/node@20.17.19)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)): + jest-config@30.3.0(@types/node@20.17.19): dependencies: '@babel/core': 7.29.0 '@jest/get-type': 30.1.0 @@ -32928,7 +32924,7 @@ snapshots: deepmerge: 4.3.1 glob: 10.5.0 graceful-fs: 4.2.11 - jest-circus: 30.3.0(babel-plugin-macros@3.1.0) + jest-circus: 30.3.0 jest-docblock: 30.2.0 jest-environment-node: 30.3.0 jest-regex-util: 30.0.1 @@ -32942,12 +32938,11 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 20.17.19 - esbuild-register: 3.6.0(esbuild@0.28.0) transitivePeerDependencies: - babel-plugin-macros - supports-color - jest-config@30.3.0(@types/node@22.9.3)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)): + jest-config@30.3.0(@types/node@22.9.3): dependencies: '@babel/core': 7.29.0 '@jest/get-type': 30.1.0 @@ -32960,7 +32955,7 @@ snapshots: deepmerge: 4.3.1 glob: 10.5.0 graceful-fs: 4.2.11 - jest-circus: 30.3.0(babel-plugin-macros@3.1.0) + jest-circus: 30.3.0 jest-docblock: 30.2.0 jest-environment-node: 30.3.0 jest-regex-util: 30.0.1 @@ -32974,7 +32969,6 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 22.9.3 - esbuild-register: 3.6.0(esbuild@0.28.0) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -37726,14 +37720,12 @@ snapshots: optionalDependencies: file-loader: 6.2.0(webpack@4.47.0) - url-loader@4.1.1(file-loader@6.2.0(webpack@5.105.4))(webpack@5.105.4): + url-loader@4.1.1(webpack@5.105.4): dependencies: loader-utils: 2.0.4 mime-types: 2.1.35 schema-utils: 3.3.0 webpack: 5.105.4 - optionalDependencies: - file-loader: 6.2.0(webpack@5.105.4) url@0.10.3: dependencies: @@ -37962,6 +37954,17 @@ snapshots: '@types/webpack': 4.41.32 webpack: 5.105.4 + webpack-dev-middleware@7.4.5(webpack@5.105.4): + dependencies: + colorette: 2.0.20 + memfs: 4.57.1 + mime-types: 3.0.2 + on-finished: 2.4.1 + range-parser: 1.2.1 + schema-utils: 4.3.3 + optionalDependencies: + webpack: 5.105.4 + webpack-dev-server@4.9.3(@types/webpack@4.41.32)(webpack@4.47.0): dependencies: '@types/bonjour': 3.5.13 @@ -38045,7 +38048,7 @@ snapshots: - utf-8-validate optional: true - webpack-dev-server@5.2.3(@types/webpack@4.41.32)(webpack@5.105.4): + webpack-dev-server@5.2.3(webpack@5.105.4): dependencies: '@types/bonjour': 3.5.13 '@types/connect-history-api-fallback': 1.5.4 @@ -38074,10 +38077,9 @@ snapshots: serve-index: 1.9.2 sockjs: 0.3.24 spdy: 4.0.2 - webpack-dev-middleware: 7.4.5(@types/webpack@4.41.32)(webpack@5.105.4) + webpack-dev-middleware: 7.4.5(webpack@5.105.4) ws: 8.21.0 optionalDependencies: - '@types/webpack': 4.41.32 webpack: 5.105.4 transitivePeerDependencies: - bufferutil diff --git a/common/config/subspaces/default/repo-state.json b/common/config/subspaces/default/repo-state.json index e53c1789de1..61a8682c971 100644 --- a/common/config/subspaces/default/repo-state.json +++ b/common/config/subspaces/default/repo-state.json @@ -1,5 +1,5 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "f1240bb4368406615df1628e5fe9369e8d232f6a", + "pnpmShrinkwrapHash": "0cdaaac7c5ac76a646450777edcb5277afddf107", "preferredVersionsHash": "029c99bd6e65c5e1f25e2848340509811ff9753c" } diff --git a/rush-plugins/rush-serve-plugin/README.md b/rush-plugins/rush-serve-plugin/README.md index b556ce248c8..8a9bfe837a4 100644 --- a/rush-plugins/rush-serve-plugin/README.md +++ b/rush-plugins/rush-serve-plugin/README.md @@ -22,7 +22,7 @@ This plugin also provides a web socket server that notifies clients of the build The recommended way to connect to the web socket is to serve a static HTML page from the serve plugin using the `globalRouting` configuration. -This package includes the reference `@rushstack/rush-serve-dashboard` web app, which speaks the plugin's WebSocket protocol. Its source lives under `apps/rush-serve-dashboard/`, and its browser-ready assets are copied into this package at `lib-esm/dashboard/`. Because the HTML references `dashboard.js` plus scoped styles under `styles/`, serve the ESM build folder rather than a single file: +This package includes the reference `@rushstack/rush-serve-dashboard` web app, which speaks the plugin's WebSocket protocol. Its source lives under `apps/rush-serve-dashboard/`, and its browser-ready JavaScript and CSS bundles are generated in this package at `lib-esm/dashboard/`. Serve the ESM build folder rather than a single file: ```json { @@ -39,22 +39,17 @@ This package includes the reference `@rushstack/rush-serve-dashboard` web app, w } ``` -The Rush server resolves extensionless module requests to emitted `.js` files. - Then open `https://localhost:/dashboard/dashboard.html`. ### Dashboard The dashboard provides table and dependency-graph views of Rush operations. Use search or the -failed/warning filter to narrow the current view, select operations to include their dependencies or consumers, -and open the terminal pane to inspect operation logs. The current view and filter are reflected in the `view` -and `filter` URL parameters so a dashboard view can be bookmarked. +failed/warning filter to narrow the current view, select operations to include their dependencies or consumers, and open the terminal pane to inspect operation logs. The current view and filter are reflected in the `view` and `filter` URL parameters so a dashboard view can be bookmarked. When connected, the dashboard can execute or abort a build, pause the watcher, change parallelism, invalidate operations, close runners, and change operation enabled states. Enabled-state changes default to safe mode; unsafe mode enables changes that can leave downstream state inconsistent. The dashboard reconnects -automatically after an unexpected disconnect. A manual disconnect remains disconnected until the Connect -button is selected. +automatically after an unexpected disconnect. A manual disconnect remains disconnected until the Connect button is selected. Because the WebSocket protocol accepts build-control commands, do not expose the dashboard or socket endpoint to untrusted networks. Apply the same authentication and network restrictions used for the served project. diff --git a/rush-plugins/rush-serve-plugin/config/heft.json b/rush-plugins/rush-serve-plugin/config/heft.json index 6f8c256114d..d440396ec4e 100644 --- a/rush-plugins/rush-serve-plugin/config/heft.json +++ b/rush-plugins/rush-serve-plugin/config/heft.json @@ -21,21 +21,10 @@ } } }, - "copy-dashboard-assets": { + "webpack": { + "taskDependencies": ["typescript"], "taskPlugin": { - "pluginPackage": "@rushstack/heft", - "pluginName": "copy-files-plugin", - "options": { - "copyOperations": [ - { - "sourcePath": "node_modules/@rushstack/rush-serve-dashboard/lib-esm", - "destinationFolders": ["lib-commonjs/dashboard", "lib-esm/dashboard"], - "fileExtensions": [".js", ".js.map", ".html", ".css"], - "excludeGlobs": ["test/**"], - "hardlink": true - } - ] - } + "pluginPackage": "@rushstack/heft-webpack5-plugin" } } } diff --git a/rush-plugins/rush-serve-plugin/package.json b/rush-plugins/rush-serve-plugin/package.json index 51fcd0ed722..a4b1cc84fef 100644 --- a/rush-plugins/rush-serve-plugin/package.json +++ b/rush-plugins/rush-serve-plugin/package.json @@ -32,13 +32,16 @@ }, "devDependencies": { "@rushstack/heft": "workspace:*", + "@rushstack/heft-webpack5-plugin": "workspace:*", "@rushstack/rush-serve-dashboard": "workspace:*", + "html-webpack-plugin": "~5.5.0", "eslint": "~9.37.0", "local-node-rig": "workspace:*", "@types/compression": "~1.7.2", "@types/cors": "~2.8.12", "@types/express": "4.17.21", - "@types/ws": "8.18.1" + "@types/ws": "8.18.1", + "webpack": "~5.105.2" }, "exports": { ".": { diff --git a/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts b/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts index 40c4d42b7c5..a135c7aec04 100644 --- a/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts +++ b/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts @@ -195,7 +195,6 @@ 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/webpack.config.js b/rush-plugins/rush-serve-plugin/webpack.config.js new file mode 100644 index 00000000000..01f496fe015 --- /dev/null +++ b/rush-plugins/rush-serve-plugin/webpack.config.js @@ -0,0 +1,49 @@ +'use strict'; + +const path = require('node:path'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); + +const dashboardPackageFolder = path.dirname(require.resolve('@rushstack/rush-serve-dashboard/package.json')); + +function createWebpackConfig({ production }) { + return ['lib-commonjs', 'lib-esm'].map((outputFolderName) => ({ + mode: production ? 'production' : 'development', + target: 'web', + devtool: 'source-map', + experiments: { + css: true + }, + entry: [ + path.join(dashboardPackageFolder, 'lib-esm', 'styles', 'dashboard.css'), + path.join(dashboardPackageFolder, 'lib-esm', 'styles', 'top-bar.css'), + path.join(dashboardPackageFolder, 'lib-esm', 'styles', 'selection-bar.css'), + path.join(dashboardPackageFolder, 'lib-esm', 'styles', 'table-view.css'), + path.join(dashboardPackageFolder, 'lib-esm', 'styles', 'graph-view.css'), + path.join(dashboardPackageFolder, 'lib-esm', 'styles', 'terminal-pane.css'), + path.join(dashboardPackageFolder, 'lib-esm', 'dashboard.js') + ], + module: { + rules: [ + { + test: /\.css$/i, + type: 'css' + } + ] + }, + output: { + path: path.join(__dirname, outputFolderName, 'dashboard'), + filename: 'dashboard.js', + cssFilename: 'dashboard.css' + }, + plugins: [ + new HtmlWebpackPlugin({ + filename: 'dashboard.html', + inject: 'body', + scriptLoading: 'module', + template: path.join(dashboardPackageFolder, 'lib-esm', 'dashboard.html') + }) + ] + })); +} + +module.exports = createWebpackConfig; From f691a6976f94a71903d29ff879fa01231e49b429 Mon Sep 17 00:00:00 2001 From: Mo Jazayeri Date: Tue, 4 Aug 2026 05:07:02 +0000 Subject: [PATCH 11/12] Fix dashboard CSS module bundling --- apps/rush-serve-dashboard/package.json | 7 +- apps/rush-serve-dashboard/src/dashboard.html | 2 +- apps/rush-serve-dashboard/src/dashboard.ts | 6 +- .../src/modules/graphSelection.ts | 6 +- .../src/modules/graphView.ts | 70 +++++++++------- .../src/modules/phaseLegend.ts | 60 +++++++------- .../src/modules/tableView.ts | 20 ++--- .../src/styles/{dashboard.css => global.css} | 4 + .../{graph-view.css => graphView.module.css} | 42 +++++----- .../{table-view.css => tableView.module.css} | 4 +- .../src/test/graphSelection.test.ts | 3 +- .../src/test/graphView.test.ts | 13 ++- .../src/test/tableView.test.ts | 7 +- .../config/subspaces/default/pnpm-lock.yaml | 81 +++++++++---------- .../config/subspaces/default/repo-state.json | 2 +- .../rush-serve-plugin/webpack.config.js | 19 ++--- 16 files changed, 191 insertions(+), 155 deletions(-) rename apps/rush-serve-dashboard/src/styles/{dashboard.css => global.css} (99%) rename apps/rush-serve-dashboard/src/styles/{graph-view.css => graphView.module.css} (90%) rename apps/rush-serve-dashboard/src/styles/{table-view.css => tableView.module.css} (96%) diff --git a/apps/rush-serve-dashboard/package.json b/apps/rush-serve-dashboard/package.json index 45813b1b61c..df19cbc9db8 100644 --- a/apps/rush-serve-dashboard/package.json +++ b/apps/rush-serve-dashboard/package.json @@ -10,10 +10,15 @@ "_phase:build": "heft run --only build -- --clean", "_phase:test": "heft run --only test -- --clean" }, + "dependencies": { + "tslib": "~2.8.1" + }, "devDependencies": { "@rushstack/heft": "workspace:*", "eslint": "~9.37.0", "local-web-rig": "workspace:*" }, - "sideEffects": false + "sideEffects": [ + "**/*.css" + ] } diff --git a/apps/rush-serve-dashboard/src/dashboard.html b/apps/rush-serve-dashboard/src/dashboard.html index cdb2129a414..a77a1f37324 100644 --- a/apps/rush-serve-dashboard/src/dashboard.html +++ b/apps/rush-serve-dashboard/src/dashboard.html @@ -193,7 +193,7 @@

Phases

-
+
diff --git a/apps/rush-serve-dashboard/src/dashboard.ts b/apps/rush-serve-dashboard/src/dashboard.ts index 31a1d3aaf25..4f482040b86 100644 --- a/apps/rush-serve-dashboard/src/dashboard.ts +++ b/apps/rush-serve-dashboard/src/dashboard.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import './styles/global.css'; + import { applyExecutionStates as applyExecutionStatesMutation, patchOperationsFromPayload, @@ -116,9 +118,7 @@ if (!edgesSvg) { } const graphEl: HTMLElement = document.getElementById('graph') as HTMLElement; const legendEl: HTMLElement | undefined = document.getElementById('graph-legend') ?? undefined; -const phasePaneEl: HTMLElement | undefined = document.getElementById('phase-pane') ?? undefined; -const phaseGroupsEl: HTMLElement | undefined = - phasePaneEl?.querySelector('.phase-groups') ?? undefined; +const phaseGroupsEl: HTMLElement | undefined = document.getElementById('phase-groups') ?? undefined; const playPauseBtn: HTMLButtonElement | undefined = (document.getElementById('play-pause-btn') as HTMLButtonElement | null) ?? undefined; const parallelismInput: HTMLInputElement | undefined = diff --git a/apps/rush-serve-dashboard/src/modules/graphSelection.ts b/apps/rush-serve-dashboard/src/modules/graphSelection.ts index e7381664e45..ae423625c93 100644 --- a/apps/rush-serve-dashboard/src/modules/graphSelection.ts +++ b/apps/rush-serve-dashboard/src/modules/graphSelection.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import graphStyles from '../styles/graphView.module.css'; + interface IOperationInfo { name: string; dependencies?: string[]; @@ -101,14 +103,14 @@ export function createGraphSelectionController( 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; + if (target && target.closest && target.closest(`.${graphStyles['op-node']}`)) return; dragSelecting = true; dragStart = _graphPointFromEvent(e); dragLast = dragStart; preDragSelection = new Set(options.getSelection()); graphMarqueeEl = document.createElement('div'); - graphMarqueeEl.className = 'graph-marquee'; + graphMarqueeEl.className = graphStyles['graph-marquee']; options.graphEl.appendChild(graphMarqueeEl); _updateMarquee(); e.preventDefault(); diff --git a/apps/rush-serve-dashboard/src/modules/graphView.ts b/apps/rush-serve-dashboard/src/modules/graphView.ts index b3d21e2c5d5..e730b895f2b 100644 --- a/apps/rush-serve-dashboard/src/modules/graphView.ts +++ b/apps/rush-serve-dashboard/src/modules/graphView.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import graphStyles from '../styles/graphView.module.css'; import { pruneGraphOperations } from './graphFiltering'; interface IOperationLogFileURLs { @@ -272,15 +273,15 @@ export function createGraphViewController(options: IGraphViewControllerOptions): const button: HTMLButtonElement = document.createElement('button'); button.type = 'button'; button.setAttribute('aria-label', name); - button.className = 'op-node'; + button.className = graphStyles['op-node']; button.dataset.name = name; button.style.transform = `translate(${x}px, ${y}px)`; const emojiSpan: HTMLSpanElement = document.createElement('span'); - emojiSpan.className = 'emoji'; + emojiSpan.className = graphStyles.emoji; emojiSpan.textContent = options.getStatusEmoji(options.getComputeDisplayStatus(op)); button.appendChild(emojiSpan); const enabledSup: HTMLSpanElement = document.createElement('span'); - enabledSup.className = 'enabled-indicator'; + enabledSup.className = graphStyles['enabled-indicator']; enabledSup.textContent = ''; button.appendChild(enabledSup); button.addEventListener('click', (e) => { @@ -372,13 +373,13 @@ export function createGraphViewController(options: IGraphViewControllerOptions): const isQueuedNext: boolean = !!(queuedState && queuedState.runInThisIteration === true); const isFilteredOut: boolean = options.getFilteredOutNames().has(name); const isSearchFiltered: boolean = options.getSearchFilteredOutNames().has(name); - const emojiSpan: Element | null = div.querySelector('.emoji'); + const emojiSpan: Element | undefined = div.getElementsByClassName(graphStyles.emoji)[0]; if (emojiSpan && (prevStatus !== displayStatus || !emojiSpan.textContent)) { emojiSpan.textContent = options.getStatusEmoji(displayStatus); } - const enabledSpan: HTMLSpanElement | null = div.querySelector( - '.enabled-indicator' - ) as HTMLSpanElement | null; + const enabledSpan: HTMLSpanElement | undefined = div.getElementsByClassName( + graphStyles['enabled-indicator'] + )[0] as HTMLSpanElement | undefined; if (enabledSpan) { let indicator: string = ''; if (op.noop) { @@ -410,15 +411,15 @@ export function createGraphViewController(options: IGraphViewControllerOptions): div.style.borderColor = baseColor; const isSelected: boolean = options.getSelection().has(name); div.setAttribute('aria-pressed', String(isSelected)); - if (isSelected) div.classList.add('selected'); - else div.classList.remove('selected'); - let activeSpan: HTMLSpanElement | null = div.querySelector( - '.active-indicator' - ) as HTMLSpanElement | null; + if (isSelected) div.classList.add(graphStyles.selected); + else div.classList.remove(graphStyles.selected); + let activeSpan: HTMLSpanElement | undefined = div.getElementsByClassName( + graphStyles['active-indicator'] + )[0] as HTMLSpanElement | undefined; if (op.isActive) { if (!activeSpan) { activeSpan = document.createElement('span'); - activeSpan.className = 'active-indicator'; + activeSpan.className = graphStyles['active-indicator']; activeSpan.textContent = '⚑'; div.appendChild(activeSpan); } @@ -426,13 +427,13 @@ export function createGraphViewController(options: IGraphViewControllerOptions): } else if (activeSpan) { activeSpan.remove(); } - let pendingSpan: HTMLSpanElement | null = div.querySelector( - '.pending-indicator' - ) as HTMLSpanElement | null; + let pendingSpan: HTMLSpanElement | undefined = div.getElementsByClassName( + graphStyles['pending-indicator'] + )[0] as HTMLSpanElement | undefined; if (isQueuedNext) { if (!pendingSpan) { pendingSpan = document.createElement('span'); - pendingSpan.className = 'pending-indicator'; + pendingSpan.className = graphStyles['pending-indicator']; pendingSpan.textContent = 'πŸ•’'; div.appendChild(pendingSpan); } @@ -440,13 +441,19 @@ export function createGraphViewController(options: IGraphViewControllerOptions): } else if (pendingSpan) { pendingSpan.remove(); } - div.classList.remove('not-running', 'filtered-out', 'filtered-out-search', 'dashed', 'dotted'); + div.classList.remove( + graphStyles['not-running'], + graphStyles['filtered-out'], + graphStyles['filtered-out-search'], + graphStyles.dashed, + graphStyles.dotted + ); if (isSearchFiltered) { - div.classList.add('filtered-out-search'); + div.classList.add(graphStyles['filtered-out-search']); } else if (isFilteredOut) { - div.classList.add('filtered-out'); + div.classList.add(graphStyles['filtered-out']); } else if (notRunning) { - div.classList.add('not-running'); + div.classList.add(graphStyles['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); @@ -499,14 +506,19 @@ export function createGraphViewController(options: IGraphViewControllerOptions): rec.path.setAttribute('d', d); const depStatus: string = graphState.nodeStatus.get(rec.to) || 'Ready'; rec.path.setAttribute('stroke', statusColors[depStatus] || '#4b5563'); - rec.path.setAttribute('class', 'edge'); + rec.path.setAttribute('class', graphStyles.edge); rec.path.setAttribute('marker-end', `url(#arrowhead-${depStatus})`); const fromOp: IOperationInfo | undefined = options.getOperations().get(rec.from); if (options.getSelection().has(rec.to) && options.getSelection().has(rec.from)) - rec.path.classList.add('highlight'); - else rec.path.classList.remove('highlight'); - rec.path.classList.remove('dashed', 'dotted', 'filtered-out', 'not-running'); - rec.path.classList.remove('filtered-out-search'); + rec.path.classList.add(graphStyles.highlight); + else rec.path.classList.remove(graphStyles.highlight); + rec.path.classList.remove( + graphStyles.dashed, + graphStyles.dotted, + graphStyles['filtered-out'], + graphStyles['not-running'] + ); + rec.path.classList.remove(graphStyles['filtered-out-search']); const fromState: IOperationExecutionState | undefined = fromOp ? options.getExecutionStates().get(rec.from) : undefined; @@ -535,10 +547,10 @@ export function createGraphViewController(options: IGraphViewControllerOptions): } else { rec.path.style.opacity = ''; const semImportant: boolean = depStatus === 'Executing' || depStatus === 'Failure'; - if (!semImportant && !rec.path.classList.contains('highlight')) { - rec.path.classList.add('dim'); + if (!semImportant && !rec.path.classList.contains(graphStyles.highlight)) { + rec.path.classList.add(graphStyles.dim); } else { - rec.path.classList.remove('dim'); + rec.path.classList.remove(graphStyles.dim); } } } diff --git a/apps/rush-serve-dashboard/src/modules/phaseLegend.ts b/apps/rush-serve-dashboard/src/modules/phaseLegend.ts index 3fe500eb458..94494861bcb 100644 --- a/apps/rush-serve-dashboard/src/modules/phaseLegend.ts +++ b/apps/rush-serve-dashboard/src/modules/phaseLegend.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import graphStyles from '../styles/graphView.module.css'; + interface IPhaseLegendOperation { name: string; phaseName?: string; @@ -129,7 +131,7 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio if (!summaries.length) { const empty: HTMLDivElement = document.createElement('div'); - empty.className = 'phase-pane-empty'; + empty.className = graphStyles['phase-pane-empty']; empty.textContent = 'No phases'; options.phaseGroupsEl.appendChild(empty); return; @@ -137,17 +139,17 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio for (const summary of summaries) { const group: HTMLDivElement = document.createElement('div'); - group.className = 'phase-group'; + group.className = graphStyles['phase-group']; const header: HTMLDivElement = document.createElement('div'); - header.className = 'phase-header'; + header.className = graphStyles['phase-header']; const emoji: HTMLSpanElement = document.createElement('span'); - emoji.className = 'phase-status-emoji'; + emoji.className = graphStyles['phase-status-emoji']; emoji.textContent = options.statusEmoji(summary.status); const nameSpan: HTMLSpanElement = document.createElement('span'); - nameSpan.className = 'phase-name'; + nameSpan.className = graphStyles['phase-name']; nameSpan.textContent = summary.phase.replace(/^_phase:/, ''); header.appendChild(emoji); @@ -156,7 +158,7 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio if (summary.problemOps.length) { const list: HTMLUListElement = document.createElement('ul'); - list.className = 'phase-problems'; + list.className = graphStyles['phase-problems']; const sortedProblems: IPhaseProblemOperation[] = [...summary.problemOps].sort((a, b) => { const ai: number = phaseStatusPriorityIndex.get(a.displayStatus) ?? 999; @@ -173,7 +175,7 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio const item: HTMLLIElement = document.createElement('li'); const status: HTMLSpanElement = document.createElement('span'); - status.className = 'phase-problem-emoji'; + status.className = graphStyles['phase-problem-emoji']; status.textContent = options.statusEmoji(displayStatus); item.appendChild(status); @@ -232,12 +234,12 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio legendEl._initialized = true; const collapsed: boolean = window.localStorage.getItem('rushServeLegendCollapsed') === '1'; - if (collapsed) legendEl.classList.add('collapsed'); + if (collapsed) legendEl.classList.add(graphStyles.collapsed); toggleBtn.textContent = collapsed ? '+' : 'βˆ’'; toggleBtn.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); toggleBtn.addEventListener('click', () => { - const isCollapsed: boolean = legendEl.classList.toggle('collapsed'); + const isCollapsed: boolean = legendEl.classList.toggle(graphStyles.collapsed); window.localStorage.setItem('rushServeLegendCollapsed', isCollapsed ? '1' : '0'); toggleBtn.textContent = isCollapsed ? '+' : 'βˆ’'; toggleBtn.setAttribute('aria-label', isCollapsed ? 'Expand legend' : 'Collapse legend'); @@ -252,7 +254,7 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio legendEl.removeChild(lastChild); } - if (legendEl.classList.contains('collapsed')) { + if (legendEl.classList.contains(graphStyles.collapsed)) { const stub: HTMLDivElement = document.createElement('div'); stub.style.fontSize = '0.5rem'; stub.style.opacity = '0.7'; @@ -264,27 +266,27 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio const statusColors: Record = options.getStatusColors(); const columnsWrap: HTMLDivElement = document.createElement('div'); - columnsWrap.className = 'legend-columns'; + columnsWrap.className = graphStyles['legend-columns']; const colPrimary: HTMLDivElement = document.createElement('div'); - colPrimary.className = 'legend-col'; + colPrimary.className = graphStyles['legend-col']; const primaryHead: HTMLDivElement = document.createElement('div'); - primaryHead.className = 'legend-heading'; + primaryHead.className = graphStyles['legend-heading']; primaryHead.textContent = 'Statuses'; colPrimary.appendChild(primaryHead); for (const status of legendOrder) { const row: HTMLDivElement = document.createElement('div'); - row.className = 'legend-row'; + row.className = graphStyles['legend-row']; const sample: HTMLSpanElement = document.createElement('span'); - sample.className = 'legend-emoji'; + sample.className = graphStyles['legend-emoji']; sample.textContent = options.statusEmoji(status); sample.style.borderColor = statusColors[status] || '#4b5563'; const labelWrap: HTMLDivElement = document.createElement('div'); - labelWrap.className = 'legend-label-wrap'; + labelWrap.className = graphStyles['legend-label-wrap']; const titleSpan: HTMLSpanElement = document.createElement('span'); titleSpan.textContent = options.overallStatusText(status); labelWrap.appendChild(titleSpan); @@ -295,15 +297,15 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio } const unknownRow: HTMLDivElement = document.createElement('div'); - unknownRow.className = 'legend-row'; + unknownRow.className = graphStyles['legend-row']; const unknownSample: HTMLSpanElement = document.createElement('span'); - unknownSample.className = 'legend-emoji status-Unknown'; + unknownSample.className = `${graphStyles['legend-emoji']} status-Unknown`; unknownSample.textContent = '❓'; unknownSample.style.borderColor = '#4b5563'; const unknownLabelWrap: HTMLDivElement = document.createElement('div'); - unknownLabelWrap.className = 'legend-label-wrap'; + unknownLabelWrap.className = graphStyles['legend-label-wrap']; const unknownTitle: HTMLSpanElement = document.createElement('span'); unknownTitle.textContent = 'UNKNOWN'; @@ -318,20 +320,20 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio colPrimary.appendChild(unknownRow); const colSecondary: HTMLDivElement = document.createElement('div'); - colSecondary.className = 'legend-col'; + colSecondary.className = graphStyles['legend-col']; const secondaryHead: HTMLDivElement = document.createElement('div'); - secondaryHead.className = 'legend-heading'; + secondaryHead.className = graphStyles['legend-heading']; secondaryHead.textContent = 'State Modifiers'; colSecondary.appendChild(secondaryHead); const addModifier = (sampleFactory: () => HTMLElement, label: string, detail?: string): void => { const row: HTMLDivElement = document.createElement('div'); - row.className = 'legend-row'; + row.className = graphStyles['legend-row']; const sample: HTMLElement = sampleFactory(); const labelWrap: HTMLDivElement = document.createElement('div'); - labelWrap.className = 'legend-label-wrap'; + labelWrap.className = graphStyles['legend-label-wrap']; const titleSpan: HTMLSpanElement = document.createElement('span'); titleSpan.textContent = label; labelWrap.appendChild(titleSpan); @@ -349,7 +351,7 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio const makeNodeBox = (borderStyle?: string, boxShadow?: string, borderColor?: string): HTMLElement => { const sample: HTMLSpanElement = document.createElement('span'); - sample.className = 'legend-emoji'; + sample.className = graphStyles['legend-emoji']; if (borderStyle) sample.style.borderStyle = borderStyle; if (borderColor) sample.style.borderColor = borderColor; if (boxShadow) sample.style.boxShadow = boxShadow; @@ -362,7 +364,7 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio const makeActive = (): HTMLElement => { const wrap: HTMLDivElement = document.createElement('div'); - wrap.className = 'legend-enabled-sample'; + wrap.className = graphStyles['legend-enabled-sample']; const base: HTMLSpanElement = document.createElement('span'); base.style.opacity = '0.15'; @@ -384,7 +386,7 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio const makePending = (): HTMLElement => { const wrap: HTMLDivElement = document.createElement('div'); - wrap.className = 'legend-enabled-sample'; + wrap.className = graphStyles['legend-enabled-sample']; const base: HTMLSpanElement = document.createElement('span'); base.style.opacity = '0.15'; @@ -406,9 +408,9 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio const makeEnabledSample = (emoji: string): HTMLElement => { const wrap: HTMLDivElement = document.createElement('div'); - wrap.className = 'legend-enabled-sample'; + wrap.className = graphStyles['legend-enabled-sample']; const sub: HTMLSpanElement = document.createElement('span'); - sub.className = 'sub'; + sub.className = graphStyles.sub; sub.textContent = emoji; wrap.appendChild(sub); return wrap; @@ -420,7 +422,7 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio addModifier(makeDotted, 'Filtered out', 'Hidden by view/search'); const enabledHead: HTMLDivElement = document.createElement('div'); - enabledHead.className = 'legend-subheading'; + enabledHead.className = graphStyles['legend-subheading']; enabledHead.textContent = 'Enabled States'; colSecondary.appendChild(enabledHead); diff --git a/apps/rush-serve-dashboard/src/modules/tableView.ts b/apps/rush-serve-dashboard/src/modules/tableView.ts index e7e440ba300..54663af3c9f 100644 --- a/apps/rush-serve-dashboard/src/modules/tableView.ts +++ b/apps/rush-serve-dashboard/src/modules/tableView.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import tableStyles from '../styles/tableView.module.css'; + interface ITableAnchorCoordinate { row: number; phase: number; @@ -183,7 +185,6 @@ export function createTableViewController(options: ITableViewControllerOptions): 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[] = []; @@ -202,14 +203,13 @@ export function createTableViewController(options: ITableViewControllerOptions): 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'); + if (allSelected) tr.classList.add(tableStyles.selected); const pkgTd: HTMLTableCellElement = document.createElement('td'); - pkgTd.className = 'pkg-cell'; + pkgTd.className = tableStyles['pkg-cell']; pkgTd.textContent = pkg.packageName; pkgTd.style.fontWeight = '600'; pkgTd.style.cursor = 'pointer'; @@ -221,7 +221,7 @@ export function createTableViewController(options: ITableViewControllerOptions): phases.forEach((phase, phaseIndex) => { const td: HTMLTableCellElement = document.createElement('td'); - td.className = 'pivot-cell'; + td.className = tableStyles['pivot-cell']; td.style.whiteSpace = 'nowrap'; const op: ITableOperation | undefined = pkg.byPhase.get(phase); @@ -229,15 +229,17 @@ export function createTableViewController(options: ITableViewControllerOptions): opCount++; const displayStatus: string = options.computeDisplayStatus(op); const glyph: string = options.enabledGlyph(op); - const active: string = op.isActive ? '⚑' : ''; + const active: string = op.isActive + ? `⚑` + : ''; td.innerHTML = ` - ${options.statusEmoji(displayStatus)} + ${options.statusEmoji(displayStatus)} ${escapeHtml(options.overallStatusText(displayStatus))} - ${glyph} + ${glyph} ${active} `; td.title = options.buildTooltip(op, displayStatus); - if (selection.has(op.name)) td.classList.add('selected'); + if (selection.has(op.name)) td.classList.add(tableStyles.selected); td.style.cursor = 'pointer'; tableOpOrder.push(op.name); td.addEventListener('click', (e: Event) => { diff --git a/apps/rush-serve-dashboard/src/styles/dashboard.css b/apps/rush-serve-dashboard/src/styles/global.css similarity index 99% rename from apps/rush-serve-dashboard/src/styles/dashboard.css rename to apps/rush-serve-dashboard/src/styles/global.css index bfcc9201b1b..06f4b59b167 100644 --- a/apps/rush-serve-dashboard/src/styles/dashboard.css +++ b/apps/rush-serve-dashboard/src/styles/global.css @@ -1,6 +1,10 @@ /* Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. */ /* See LICENSE in the project root for license information. */ +@import './top-bar.css'; +@import './selection-bar.css'; +@import './terminal-pane.css'; + :root { --bg: #0f1115; --panel: #1b1f27; diff --git a/apps/rush-serve-dashboard/src/styles/graph-view.css b/apps/rush-serve-dashboard/src/styles/graphView.module.css similarity index 90% rename from apps/rush-serve-dashboard/src/styles/graph-view.css rename to apps/rush-serve-dashboard/src/styles/graphView.module.css index 6fae97bf70a..57ddff563ca 100644 --- a/apps/rush-serve-dashboard/src/styles/graph-view.css +++ b/apps/rush-serve-dashboard/src/styles/graphView.module.css @@ -1,7 +1,7 @@ /* Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. */ /* See LICENSE in the project root for license information. */ -#graph-container { +:global(#graph-container) { position: relative; flex: 1; min-height: 0; @@ -11,7 +11,7 @@ gap: 0; } -#graph { +:global(#graph) { width: 100%; height: 100%; position: relative; @@ -19,7 +19,7 @@ background: radial-gradient(circle at 25% 20%, #1e2530, #101318); } -#phase-pane { +:global(#phase-pane) { width: 220px; background: var(--panel); border-right: 1px solid var(--border); @@ -30,7 +30,7 @@ position: relative; } -#phase-pane h3 { +:global(#phase-pane) h3 { margin: 0 0 0.4rem; font-size: 0.68rem; letter-spacing: 0.5px; @@ -48,7 +48,7 @@ min-height: 0; } -#phase-pane .phase-groups { +:global(#phase-groups) { display: flex; flex-direction: column; gap: 0.6rem; @@ -56,7 +56,7 @@ overflow: hidden; } -#phase-pane .phase-groups > .phase-group { +:global(#phase-groups) > .phase-group { flex: 1 1 0; } @@ -119,13 +119,13 @@ font-size: 0.6rem; } -#graph-wrapper { +:global(#graph-wrapper) { flex: 1; min-height: 0; position: relative; } -#graph-legend { +:global(#graph-legend) { position: absolute; right: 28px; bottom: 28px; @@ -140,31 +140,31 @@ pointer-events: auto; } -#graph-legend .legend-columns { +:global(#graph-legend) .legend-columns { display: flex; gap: 10px; align-items: flex-start; } -#graph-legend .legend-col:first-child { +:global(#graph-legend) .legend-col:first-child { flex: 0 0 130px; } -#graph-legend .legend-col:last-child { +:global(#graph-legend) .legend-col:last-child { flex: 1 1 auto; } -#graph-legend.collapsed { +:global(#graph-legend).collapsed { width: auto; max-width: none; padding: 4px 6px; } -#graph-legend.collapsed .legend-columns { +:global(#graph-legend).collapsed .legend-columns { display: none; } -#graph-legend .legend-col { +:global(#graph-legend) .legend-col { flex: 1 1 0; min-width: 0; display: flex; @@ -172,7 +172,7 @@ gap: 2px; } -#graph-legend .legend-heading { +:global(#graph-legend) .legend-heading { font-weight: 600; letter-spacing: 0.5px; text-transform: uppercase; @@ -181,26 +181,26 @@ margin: 0 0 4px; } -#graph-legend .legend-row { +:global(#graph-legend) .legend-row { display: flex; align-items: center; gap: 5px; min-width: 0; } -#graph-legend .legend-label-wrap { +:global(#graph-legend) .legend-label-wrap { display: flex; flex-direction: column; gap: 2px; min-width: 0; } -#graph-legend .legend-row small { +:global(#graph-legend) .legend-row small { font-size: 0.5rem; opacity: 0.7; } -#graph-legend .legend-subheading { +:global(#graph-legend) .legend-subheading { margin: 4px 0 2px; font-size: 0.52rem; text-transform: uppercase; @@ -209,7 +209,7 @@ font-weight: 600; } -#graph-legend h4 { +:global(#graph-legend) h4 { margin: 0 0 4px; font-size: 0.6rem; letter-spacing: 0.6px; @@ -264,7 +264,7 @@ text-overflow: ellipsis; } -#graph svg { +:global(#graph) svg { position: absolute; top: 0; left: 0; diff --git a/apps/rush-serve-dashboard/src/styles/table-view.css b/apps/rush-serve-dashboard/src/styles/tableView.module.css similarity index 96% rename from apps/rush-serve-dashboard/src/styles/table-view.css rename to apps/rush-serve-dashboard/src/styles/tableView.module.css index 462bdd370b3..1ce80f20621 100644 --- a/apps/rush-serve-dashboard/src/styles/table-view.css +++ b/apps/rush-serve-dashboard/src/styles/tableView.module.css @@ -1,7 +1,7 @@ /* Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. */ /* See LICENSE in the project root for license information. */ -#operations-table-container { +:global(#operations-table-container) { flex: 1; min-height: 0; overflow: auto; @@ -99,7 +99,7 @@ td.status-cell .status-pill { top: 1px; } -#table-stats { +:global(#table-stats) { font-size: 0.6rem; color: var(--muted); padding: 0.25rem 0.75rem 0.5rem; diff --git a/apps/rush-serve-dashboard/src/test/graphSelection.test.ts b/apps/rush-serve-dashboard/src/test/graphSelection.test.ts index a491bc2289b..7e2293fc42f 100644 --- a/apps/rush-serve-dashboard/src/test/graphSelection.test.ts +++ b/apps/rush-serve-dashboard/src/test/graphSelection.test.ts @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. import { createGraphSelectionController } from '../modules/graphSelection'; +import graphStyles from '../styles/graphView.module.css'; describe('graph selection controller', () => { it('selects nodes and expands dependencies and consumers transitively', () => { @@ -67,6 +68,6 @@ describe('graph selection controller', () => { expect(selection).toEqual(new Set(['inside'])); expect(onLiveSelectionChanged).toHaveBeenCalled(); - expect(graphEl.querySelector('.graph-marquee')).toBeNull(); + expect(graphEl.querySelector(`.${graphStyles['graph-marquee']}`)).toBeNull(); }); }); diff --git a/apps/rush-serve-dashboard/src/test/graphView.test.ts b/apps/rush-serve-dashboard/src/test/graphView.test.ts index 12282ffd341..feaeccc4339 100644 --- a/apps/rush-serve-dashboard/src/test/graphView.test.ts +++ b/apps/rush-serve-dashboard/src/test/graphView.test.ts @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. import { createGraphViewController, graphState } from '../modules/graphView'; +import graphStyles from '../styles/graphView.module.css'; describe('graph view controller', () => { it('renders nodes, removes transitive edges, updates indicators, and wires selection', () => { @@ -46,9 +47,15 @@ describe('graph view controller', () => { 'build->compile', 'test->build' ]); - expect(graphState.nodeElements.get('build')?.querySelector('.active-indicator')).not.toBeNull(); - expect(graphState.nodeElements.get('test')?.querySelector('.pending-indicator')).not.toBeNull(); - expect(graphState.nodeElements.get('test')?.querySelector('.enabled-indicator')?.textContent).toBe('πŸ”΄'); + expect( + graphState.nodeElements.get('build')?.querySelector(`.${graphStyles['active-indicator']}`) + ).not.toBeNull(); + expect( + graphState.nodeElements.get('test')?.querySelector(`.${graphStyles['pending-indicator']}`) + ).not.toBeNull(); + expect( + graphState.nodeElements.get('test')?.querySelector(`.${graphStyles['enabled-indicator']}`)?.textContent + ).toBe('πŸ”΄'); expect(renderPhaseLegend).toHaveBeenCalled(); const compileNode: HTMLButtonElement | undefined = graphState.nodeElements.get('compile'); diff --git a/apps/rush-serve-dashboard/src/test/tableView.test.ts b/apps/rush-serve-dashboard/src/test/tableView.test.ts index 7731211f735..a304e1940e7 100644 --- a/apps/rush-serve-dashboard/src/test/tableView.test.ts +++ b/apps/rush-serve-dashboard/src/test/tableView.test.ts @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. import { createTableViewController } from '../modules/tableView'; +import tableStyles from '../styles/tableView.module.css'; describe('table view controller', () => { it('renders a package/phase pivot safely and supports cell and group selection', () => { @@ -37,11 +38,13 @@ describe('table view controller', () => { expect(document.querySelector('tbody')?.innerHTML).not.toContain(''); expect(document.getElementById('stats')?.textContent).toBe('3 operations'); - const firstOperationCell = document.querySelector('td.pivot-cell[title="a-build"]') as HTMLElement; + const firstOperationCell = document.querySelector( + `td.${tableStyles['pivot-cell']}[title="a-build"]` + ) as HTMLElement; firstOperationCell.click(); expect(selection).toEqual(new Set(['a-build'])); - const firstPackageCell = document.querySelector('.pkg-cell') as HTMLElement; + const firstPackageCell = document.querySelector(`.${tableStyles['pkg-cell']}`) as HTMLElement; firstPackageCell.click(); expect(selection).toEqual(new Set(['a-build', 'a-test'])); expect(onSelectionMutated).toHaveBeenCalledTimes(2); diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index cdd549e7fa9..4ec1b48ee88 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -454,6 +454,10 @@ importers: version: 5.8.2 ../../../apps/rush-serve-dashboard: + dependencies: + tslib: + specifier: ~2.8.1 + version: 2.8.1 devDependencies: '@rushstack/heft': specifier: workspace:* @@ -3209,7 +3213,7 @@ importers: dependencies: '@jest/core': specifier: ~30.3.0 - version: 30.3.0(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)) + version: 30.3.0 '@jest/reporters': specifier: ~30.3.0 version: 30.3.0 @@ -3227,7 +3231,7 @@ importers: version: link:../../libraries/terminal jest-config: specifier: ~30.3.0 - version: 30.3.0(@types/node@20.17.19)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)) + version: 30.3.0(@types/node@20.17.19) jest-resolve: specifier: ~30.3.0 version: 30.3.0 @@ -3642,7 +3646,7 @@ importers: version: 2.4.0 webpack-dev-server: specifier: ^5.1.0 - version: 5.2.3(@types/webpack@4.41.32)(webpack@5.105.4) + version: 5.2.3(webpack@5.105.4) devDependencies: '@rushstack/heft': specifier: workspace:* @@ -4598,7 +4602,7 @@ importers: version: 1.2.22(@types/node@20.17.19) '@rushstack/heft-node-rig': specifier: 2.11.45 - version: 2.11.45(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0) + version: 2.11.45(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19)(jest-environment-jsdom@30.3.0) '@types/jest': specifier: 30.0.0 version: 30.0.0 @@ -4789,7 +4793,7 @@ importers: version: 5.8.2 url-loader: specifier: ~4.1.1 - version: 4.1.1(file-loader@6.2.0(webpack@5.105.4))(webpack@5.105.4) + version: 4.1.1(webpack@5.105.4) webpack: specifier: ~5.105.2 version: 5.105.4 @@ -22919,7 +22923,7 @@ snapshots: - supports-color - ts-node - '@jest/core@30.3.0(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0))': + '@jest/core@30.3.0': dependencies: '@jest/console': 30.3.0 '@jest/pattern': 30.0.1 @@ -22934,7 +22938,7 @@ snapshots: exit-x: 0.2.2 graceful-fs: 4.2.11 jest-changed-files: 30.3.0 - jest-config: 30.3.0(@types/node@22.9.3)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)) + jest-config: 30.3.0(@types/node@22.9.3) jest-haste-map: 30.3.0 jest-message-util: 30.3.0 jest-regex-util: 30.0.1 @@ -24669,16 +24673,16 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@rushstack/heft-jest-plugin@2.0.12(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/jest@30.0.0)(@types/node@20.17.19)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)(jest-environment-node@30.3.0)': + '@rushstack/heft-jest-plugin@2.0.12(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/jest@30.0.0)(@types/node@20.17.19)(jest-environment-jsdom@30.3.0)(jest-environment-node@30.3.0)': dependencies: - '@jest/core': 30.3.0(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)) + '@jest/core': 30.3.0 '@jest/reporters': 30.3.0 '@jest/transform': 30.3.0 '@rushstack/heft': 1.2.22(@types/node@20.17.19) '@rushstack/heft-config-file': 0.20.12(@types/node@20.17.19) '@rushstack/node-core-library': 5.23.3(@types/node@20.17.19) '@rushstack/terminal': 0.24.2(@types/node@20.17.19) - jest-config: 30.3.0(@types/node@20.17.19)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)) + jest-config: 30.3.0(@types/node@20.17.19) jest-resolve: 30.3.0 jest-snapshot: 30.3.0 optionalDependencies: @@ -24702,13 +24706,13 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@rushstack/heft-node-rig@2.11.45(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)': + '@rushstack/heft-node-rig@2.11.45(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19)(jest-environment-jsdom@30.3.0)': dependencies: '@microsoft/api-extractor': 7.58.12(@types/node@20.17.19) '@rushstack/eslint-config': 4.6.4(eslint@9.37.0)(typescript@5.8.2) '@rushstack/heft': 1.2.22(@types/node@20.17.19) '@rushstack/heft-api-extractor-plugin': 1.3.22(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19) - '@rushstack/heft-jest-plugin': 2.0.12(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/jest@30.0.0)(@types/node@20.17.19)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)(jest-environment-node@30.3.0) + '@rushstack/heft-jest-plugin': 2.0.12(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/jest@30.0.0)(@types/node@20.17.19)(jest-environment-jsdom@30.3.0)(jest-environment-node@30.3.0) '@rushstack/heft-lint-plugin': 1.2.22(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19) '@rushstack/heft-typescript-plugin': 1.3.17(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19) '@types/jest': 30.0.0 @@ -29715,6 +29719,8 @@ snapshots: dedent@0.7.0: {} + dedent@1.7.2: {} + dedent@1.7.2(babel-plugin-macros@3.1.0): optionalDependencies: babel-plugin-macros: 3.1.0 @@ -30259,14 +30265,6 @@ snapshots: transitivePeerDependencies: - supports-color - esbuild-register@3.6.0(esbuild@0.28.0): - dependencies: - debug: 4.4.3(supports-color@8.1.1) - esbuild: 0.28.0 - transitivePeerDependencies: - - supports-color - optional: true - esbuild-runner@2.2.2(esbuild@0.14.54): dependencies: esbuild: 0.14.54 @@ -30838,7 +30836,7 @@ snapshots: eslint@8.57.1: dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.37.0) '@eslint-community/regexpp': 4.12.2 '@eslint/eslintrc': 2.1.4 '@eslint/js': 8.57.1 @@ -31428,13 +31426,6 @@ snapshots: schema-utils: 3.3.0 webpack: 4.47.0 - file-loader@6.2.0(webpack@5.105.4): - dependencies: - loader-utils: 2.0.4 - schema-utils: 3.3.0 - webpack: 5.105.4 - optional: true - file-system-cache@1.1.0: dependencies: fs-extra: 10.1.0 @@ -32822,7 +32813,7 @@ snapshots: - babel-plugin-macros - supports-color - jest-circus@30.3.0(babel-plugin-macros@3.1.0): + jest-circus@30.3.0: dependencies: '@jest/environment': 30.3.0 '@jest/expect': 30.3.0 @@ -32831,7 +32822,7 @@ snapshots: '@types/node': 22.9.3 chalk: 4.1.2 co: 4.6.0 - dedent: 1.7.2(babel-plugin-macros@3.1.0) + dedent: 1.7.2 is-generator-fn: 2.1.0 jest-each: 30.3.0 jest-matcher-utils: 30.3.0 @@ -32927,7 +32918,7 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@30.3.0(@types/node@20.17.19)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)): + jest-config@30.3.0(@types/node@20.17.19): dependencies: '@babel/core': 7.29.0 '@jest/get-type': 30.1.0 @@ -32940,7 +32931,7 @@ snapshots: deepmerge: 4.3.1 glob: 10.5.0 graceful-fs: 4.2.11 - jest-circus: 30.3.0(babel-plugin-macros@3.1.0) + jest-circus: 30.3.0 jest-docblock: 30.2.0 jest-environment-node: 30.3.0 jest-regex-util: 30.0.1 @@ -32954,12 +32945,11 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 20.17.19 - esbuild-register: 3.6.0(esbuild@0.28.0) transitivePeerDependencies: - babel-plugin-macros - supports-color - jest-config@30.3.0(@types/node@22.9.3)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)): + jest-config@30.3.0(@types/node@22.9.3): dependencies: '@babel/core': 7.29.0 '@jest/get-type': 30.1.0 @@ -32972,7 +32962,7 @@ snapshots: deepmerge: 4.3.1 glob: 10.5.0 graceful-fs: 4.2.11 - jest-circus: 30.3.0(babel-plugin-macros@3.1.0) + jest-circus: 30.3.0 jest-docblock: 30.2.0 jest-environment-node: 30.3.0 jest-regex-util: 30.0.1 @@ -32986,7 +32976,6 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 22.9.3 - esbuild-register: 3.6.0(esbuild@0.28.0) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -37738,14 +37727,12 @@ snapshots: optionalDependencies: file-loader: 6.2.0(webpack@4.47.0) - url-loader@4.1.1(file-loader@6.2.0(webpack@5.105.4))(webpack@5.105.4): + url-loader@4.1.1(webpack@5.105.4): dependencies: loader-utils: 2.0.4 mime-types: 2.1.35 schema-utils: 3.3.0 webpack: 5.105.4 - optionalDependencies: - file-loader: 6.2.0(webpack@5.105.4) url@0.10.3: dependencies: @@ -37974,6 +37961,17 @@ snapshots: '@types/webpack': 4.41.32 webpack: 5.105.4 + webpack-dev-middleware@7.4.5(webpack@5.105.4): + dependencies: + colorette: 2.0.20 + memfs: 4.57.1 + mime-types: 3.0.2 + on-finished: 2.4.1 + range-parser: 1.2.1 + schema-utils: 4.3.3 + optionalDependencies: + webpack: 5.105.4 + webpack-dev-server@4.9.3(@types/webpack@4.41.32)(webpack@4.47.0): dependencies: '@types/bonjour': 3.5.13 @@ -38057,7 +38055,7 @@ snapshots: - utf-8-validate optional: true - webpack-dev-server@5.2.3(@types/webpack@4.41.32)(webpack@5.105.4): + webpack-dev-server@5.2.3(webpack@5.105.4): dependencies: '@types/bonjour': 3.5.13 '@types/connect-history-api-fallback': 1.5.4 @@ -38086,10 +38084,9 @@ snapshots: serve-index: 1.9.2 sockjs: 0.3.24 spdy: 4.0.2 - webpack-dev-middleware: 7.4.5(@types/webpack@4.41.32)(webpack@5.105.4) + webpack-dev-middleware: 7.4.5(webpack@5.105.4) ws: 8.21.0 optionalDependencies: - '@types/webpack': 4.41.32 webpack: 5.105.4 transitivePeerDependencies: - bufferutil diff --git a/common/config/subspaces/default/repo-state.json b/common/config/subspaces/default/repo-state.json index e53c1789de1..61a8682c971 100644 --- a/common/config/subspaces/default/repo-state.json +++ b/common/config/subspaces/default/repo-state.json @@ -1,5 +1,5 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "f1240bb4368406615df1628e5fe9369e8d232f6a", + "pnpmShrinkwrapHash": "0cdaaac7c5ac76a646450777edcb5277afddf107", "preferredVersionsHash": "029c99bd6e65c5e1f25e2848340509811ff9753c" } diff --git a/rush-plugins/rush-serve-plugin/webpack.config.js b/rush-plugins/rush-serve-plugin/webpack.config.js index 01f496fe015..42fc30beffe 100644 --- a/rush-plugins/rush-serve-plugin/webpack.config.js +++ b/rush-plugins/rush-serve-plugin/webpack.config.js @@ -13,19 +13,20 @@ function createWebpackConfig({ production }) { experiments: { css: true }, - entry: [ - path.join(dashboardPackageFolder, 'lib-esm', 'styles', 'dashboard.css'), - path.join(dashboardPackageFolder, 'lib-esm', 'styles', 'top-bar.css'), - path.join(dashboardPackageFolder, 'lib-esm', 'styles', 'selection-bar.css'), - path.join(dashboardPackageFolder, 'lib-esm', 'styles', 'table-view.css'), - path.join(dashboardPackageFolder, 'lib-esm', 'styles', 'graph-view.css'), - path.join(dashboardPackageFolder, 'lib-esm', 'styles', 'terminal-pane.css'), - path.join(dashboardPackageFolder, 'lib-esm', 'dashboard.js') - ], + entry: path.join(dashboardPackageFolder, 'lib-esm', 'dashboard.js'), module: { rules: [ + { + test: /\.module\.css$/i, + type: 'css/module', + parser: { + dashedIdents: false, + namedExports: false + } + }, { test: /\.css$/i, + exclude: /\.module\.css$/i, type: 'css' } ] From 2cdac1154375b7922f3676d2b5228de494b75e7c Mon Sep 17 00:00:00 2001 From: Mo Jazayeri Date: Tue, 4 Aug 2026 06:11:11 +0000 Subject: [PATCH 12/12] Convert dashboard styles to CSS modules --- .../{src => assets}/dashboard.html | 80 ++--- apps/rush-serve-dashboard/config/heft.json | 26 +- apps/rush-serve-dashboard/src/dashboard.ts | 103 +++++- .../src/modules/graphSelection.ts | 4 +- .../src/modules/graphView.ts | 32 +- .../src/modules/phaseLegend.ts | 51 +-- .../src/modules/statusHelpers.ts | 25 ++ .../src/modules/tableView.ts | 12 +- .../src/modules/terminalPane.ts | 36 ++- .../src/modules/topBar.ts | 25 +- .../styles/{global.css => global.module.css} | 306 +++++------------- .../src/styles/graphView.module.css | 128 ++++---- ...ection-bar.css => selectionBar.module.css} | 25 +- .../src/styles/tableView.module.css | 42 +-- ...minal-pane.css => terminalPane.module.css} | 59 ++-- .../styles/{top-bar.css => topBar.module.css} | 51 +-- .../src/test/actionWiring.test.ts | 5 +- .../src/test/dashboard.test.ts | 2 +- .../src/test/graphSelection.test.ts | 2 +- .../src/test/graphView.test.ts | 6 +- .../src/test/outputPanels.test.ts | 14 +- .../src/test/tableView.test.ts | 4 +- .../rush-serve-plugin/webpack.config.js | 2 +- 23 files changed, 469 insertions(+), 571 deletions(-) rename apps/rush-serve-dashboard/{src => assets}/dashboard.html (73%) rename apps/rush-serve-dashboard/src/styles/{global.css => global.module.css} (70%) rename apps/rush-serve-dashboard/src/styles/{selection-bar.css => selectionBar.module.css} (69%) rename apps/rush-serve-dashboard/src/styles/{terminal-pane.css => terminalPane.module.css} (73%) rename apps/rush-serve-dashboard/src/styles/{top-bar.css => topBar.module.css} (67%) diff --git a/apps/rush-serve-dashboard/src/dashboard.html b/apps/rush-serve-dashboard/assets/dashboard.html similarity index 73% rename from apps/rush-serve-dashboard/src/dashboard.html rename to apps/rush-serve-dashboard/assets/dashboard.html index a77a1f37324..6f4cec2d9bf 100644 --- a/apps/rush-serve-dashboard/src/dashboard.html +++ b/apps/rush-serve-dashboard/assets/dashboard.html @@ -18,52 +18,28 @@
- Disconnected + Disconnected
Rush Serve Dashboard
- - - - - - -
-
-
+
+
- - - - +
-
+
@@ -218,28 +185,27 @@

Phases

-
-
-
Terminal Output
-
+
+
+
Terminal Output
+
-
-
+
diff --git a/apps/rush-serve-dashboard/config/heft.json b/apps/rush-serve-dashboard/config/heft.json index 55f215d764e..2086b83cf1d 100644 --- a/apps/rush-serve-dashboard/config/heft.json +++ b/apps/rush-serve-dashboard/config/heft.json @@ -1,28 +1,4 @@ { "$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft.schema.json", - - "extends": "local-web-rig/profiles/app/config/heft.json", - - "phasesByName": { - "build": { - "tasksByName": { - "copy-dashboard-assets": { - "taskPlugin": { - "pluginPackage": "@rushstack/heft", - "pluginName": "copy-files-plugin", - "options": { - "copyOperations": [ - { - "sourcePath": "src", - "destinationFolders": ["lib-esm"], - "fileExtensions": [".html"], - "hardlink": true - } - ] - } - } - } - } - } - } + "extends": "local-web-rig/profiles/app/config/heft.json" } diff --git a/apps/rush-serve-dashboard/src/dashboard.ts b/apps/rush-serve-dashboard/src/dashboard.ts index 4f482040b86..4ef26f4a83e 100644 --- a/apps/rush-serve-dashboard/src/dashboard.ts +++ b/apps/rush-serve-dashboard/src/dashboard.ts @@ -1,8 +1,12 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import './styles/global.css'; - +import globalStyles from './styles/global.module.css'; +import graphStyles from './styles/graphView.module.css'; +import selectionStyles from './styles/selectionBar.module.css'; +import tableStyles from './styles/tableView.module.css'; +import terminalStyles from './styles/terminalPane.module.css'; +import topBarStyles from './styles/topBar.module.css'; import { applyExecutionStates as applyExecutionStatesMutation, patchOperationsFromPayload, @@ -102,6 +106,101 @@ interface IGraphViewControllerLike { updateGraph(): void; } +function addClasses(id: string, ...classNames: string[]): void { + document.getElementById(id)?.classList.add(...classNames); +} + +function applyStaticStyles(): void { + document.body.classList.add(globalStyles.dashboard); + addClasses('top-bar', topBarStyles.topBar); + addClasses('overall-status', topBarStyles.overallStatus); + addClasses('status-emoji', topBarStyles.statusEmoji); + addClasses( + 'status-pill', + globalStyles.statusPill, + topBarStyles.statusPill, + globalStyles.statusDisconnected + ); + addClasses('app-title', globalStyles.appTitle); + addClasses('actions', globalStyles.actions, topBarStyles.actions); + addClasses('graph-state', globalStyles.graphState, topBarStyles.graphState); + addClasses('view-controls', globalStyles.viewControls, topBarStyles.viewControls); + addClasses('parallelism-label', globalStyles.flexRow, globalStyles.parallelismLabel); + addClasses('parallelism-input', globalStyles.parallelismInput); + addClasses('search-label', globalStyles.searchLabel); + addClasses('name-search', globalStyles.nameSearch); + addClasses('top-bar-spacer', globalStyles.flexSpacer); + addClasses( + 'connection-form', + globalStyles.flexRow, + globalStyles.connectionForm, + topBarStyles.connectionForm + ); + addClasses('connect-btn', topBarStyles.connectButton); + + const iconButtonIds: string[] = [ + 'play-pause-btn', + 'execute-btn', + 'abort-execution-btn', + 'debug-btn', + 'verbose-btn', + 'select-visible-btn', + 'connect-btn', + 'toggle-terminal-btn', + 'term-clear-btn', + 'term-autoscroll-btn' + ]; + iconButtonIds.forEach((id) => addClasses(id, globalStyles.iconBtn)); + addClasses('abort-execution-btn', globalStyles.stop); + ['debug-btn', 'verbose-btn', 'connect-btn', 'toggle-terminal-btn', 'term-autoscroll-btn'].forEach((id) => + addClasses(id, globalStyles.toggle) + ); + + addClasses('selection-bar', selectionStyles.selectionBar); + addClasses('selection-heading', selectionStyles.selectionHeading); + addClasses('selection-actions', selectionStyles.selectionActions); + const actionButtonIds: string[] = [ + 'clear-selection-btn', + 'invalidate-btn', + 'close-runners-btn', + 'set-enabled-default-btn', + 'set-enabled-ignore-deps-btn', + 'set-enabled-disabled-btn', + 'selection-mode-btn', + 'expand-deps-btn', + 'expand-consumers-btn' + ]; + actionButtonIds.forEach((id) => addClasses(id, globalStyles.action)); + addClasses('clear-selection-btn', selectionStyles.selectionCountButton); + addClasses('selection-mode-btn', globalStyles.toggle); + + addClasses('content-wrap', globalStyles.contentWrap); + addClasses('main', globalStyles.main); + addClasses('left', globalStyles.pane, globalStyles.leftPane); + addClasses('right', globalStyles.pane); + addClasses('operations-table-container', tableStyles.operationsTableContainer); + addClasses('operations-table', tableStyles.operationsTable); + addClasses('table-stats', tableStyles.tableStats); + addClasses('graph-container', graphStyles.graphContainer); + addClasses('phase-pane', graphStyles.phasePane); + addClasses('phase-groups', graphStyles.phaseGroups); + addClasses('graph-wrapper', graphStyles.graphWrapper); + addClasses('graph', graphStyles.graph); + addClasses('graph-legend', graphStyles.graphLegend); + + addClasses('resizer', terminalStyles.resizer); + addClasses('terminal', terminalStyles.terminalContainer); + addClasses('terminal-header', terminalStyles.terminalHeader); + addClasses('terminal-title', terminalStyles.terminalTitle); + addClasses('terminal-controls', terminalStyles.terminalControls); + addClasses('terminal-body', terminalStyles.terminalBody); + addClasses('term-autoscroll', terminalStyles.termAutoscroll); + addClasses('toggle-terminal-btn', terminalStyles.toggleTerminalButton); + addClasses('term-autoscroll-icon', terminalStyles.vertical); +} + +applyStaticStyles(); + const statusPill: HTMLElement = document.getElementById('status-pill') as HTMLElement; const statusEmojiEl: HTMLElement = document.getElementById('status-emoji') as HTMLElement; const connectBtn: HTMLButtonElement | undefined = diff --git a/apps/rush-serve-dashboard/src/modules/graphSelection.ts b/apps/rush-serve-dashboard/src/modules/graphSelection.ts index ae423625c93..6531c42e5e0 100644 --- a/apps/rush-serve-dashboard/src/modules/graphSelection.ts +++ b/apps/rush-serve-dashboard/src/modules/graphSelection.ts @@ -103,14 +103,14 @@ export function createGraphSelectionController( const _beginDragSelection = (e: MouseEvent): void => { if (options.getCurrentView() !== 'graph' || e.button !== 0) return; const target: Element | null = e.target as Element | null; - if (target && target.closest && target.closest(`.${graphStyles['op-node']}`)) return; + if (target && target.closest && target.closest(`.${graphStyles.opNode}`)) return; dragSelecting = true; dragStart = _graphPointFromEvent(e); dragLast = dragStart; preDragSelection = new Set(options.getSelection()); graphMarqueeEl = document.createElement('div'); - graphMarqueeEl.className = graphStyles['graph-marquee']; + graphMarqueeEl.className = graphStyles.graphMarquee; options.graphEl.appendChild(graphMarqueeEl); _updateMarquee(); e.preventDefault(); diff --git a/apps/rush-serve-dashboard/src/modules/graphView.ts b/apps/rush-serve-dashboard/src/modules/graphView.ts index e730b895f2b..3a1a2f89061 100644 --- a/apps/rush-serve-dashboard/src/modules/graphView.ts +++ b/apps/rush-serve-dashboard/src/modules/graphView.ts @@ -273,7 +273,7 @@ export function createGraphViewController(options: IGraphViewControllerOptions): const button: HTMLButtonElement = document.createElement('button'); button.type = 'button'; button.setAttribute('aria-label', name); - button.className = graphStyles['op-node']; + button.className = graphStyles.opNode; button.dataset.name = name; button.style.transform = `translate(${x}px, ${y}px)`; const emojiSpan: HTMLSpanElement = document.createElement('span'); @@ -281,7 +281,7 @@ export function createGraphViewController(options: IGraphViewControllerOptions): emojiSpan.textContent = options.getStatusEmoji(options.getComputeDisplayStatus(op)); button.appendChild(emojiSpan); const enabledSup: HTMLSpanElement = document.createElement('span'); - enabledSup.className = graphStyles['enabled-indicator']; + enabledSup.className = graphStyles.enabledIndicator; enabledSup.textContent = ''; button.appendChild(enabledSup); button.addEventListener('click', (e) => { @@ -378,7 +378,7 @@ export function createGraphViewController(options: IGraphViewControllerOptions): emojiSpan.textContent = options.getStatusEmoji(displayStatus); } const enabledSpan: HTMLSpanElement | undefined = div.getElementsByClassName( - graphStyles['enabled-indicator'] + graphStyles.enabledIndicator )[0] as HTMLSpanElement | undefined; if (enabledSpan) { let indicator: string = ''; @@ -414,12 +414,12 @@ export function createGraphViewController(options: IGraphViewControllerOptions): if (isSelected) div.classList.add(graphStyles.selected); else div.classList.remove(graphStyles.selected); let activeSpan: HTMLSpanElement | undefined = div.getElementsByClassName( - graphStyles['active-indicator'] + graphStyles.activeIndicator )[0] as HTMLSpanElement | undefined; if (op.isActive) { if (!activeSpan) { activeSpan = document.createElement('span'); - activeSpan.className = graphStyles['active-indicator']; + activeSpan.className = graphStyles.activeIndicator; activeSpan.textContent = '⚑'; div.appendChild(activeSpan); } @@ -428,12 +428,12 @@ export function createGraphViewController(options: IGraphViewControllerOptions): activeSpan.remove(); } let pendingSpan: HTMLSpanElement | undefined = div.getElementsByClassName( - graphStyles['pending-indicator'] + graphStyles.pendingIndicator )[0] as HTMLSpanElement | undefined; if (isQueuedNext) { if (!pendingSpan) { pendingSpan = document.createElement('span'); - pendingSpan.className = graphStyles['pending-indicator']; + pendingSpan.className = graphStyles.pendingIndicator; pendingSpan.textContent = 'πŸ•’'; div.appendChild(pendingSpan); } @@ -442,18 +442,18 @@ export function createGraphViewController(options: IGraphViewControllerOptions): pendingSpan.remove(); } div.classList.remove( - graphStyles['not-running'], - graphStyles['filtered-out'], - graphStyles['filtered-out-search'], + graphStyles.notRunning, + graphStyles.filteredOut, + graphStyles.filteredOutSearch, graphStyles.dashed, graphStyles.dotted ); if (isSearchFiltered) { - div.classList.add(graphStyles['filtered-out-search']); + div.classList.add(graphStyles.filteredOutSearch); } else if (isFilteredOut) { - div.classList.add(graphStyles['filtered-out']); + div.classList.add(graphStyles.filteredOut); } else if (notRunning) { - div.classList.add(graphStyles['not-running']); + div.classList.add(graphStyles.notRunning); } div.title = `${op.name}\nLast Result: ${(options.getLastExecutionResults().get(name) || {}).status || displayStatus}\n${options.getOverallStatusText(displayStatus)}${op.isActive ? '\nHas in-memory state' : ''}`; graphState.nodeStatus.set(name, displayStatus); @@ -515,10 +515,10 @@ export function createGraphViewController(options: IGraphViewControllerOptions): rec.path.classList.remove( graphStyles.dashed, graphStyles.dotted, - graphStyles['filtered-out'], - graphStyles['not-running'] + graphStyles.filteredOut, + graphStyles.notRunning ); - rec.path.classList.remove(graphStyles['filtered-out-search']); + rec.path.classList.remove(graphStyles.filteredOutSearch); const fromState: IOperationExecutionState | undefined = fromOp ? options.getExecutionStates().get(rec.from) : undefined; diff --git a/apps/rush-serve-dashboard/src/modules/phaseLegend.ts b/apps/rush-serve-dashboard/src/modules/phaseLegend.ts index 94494861bcb..4ff6f654055 100644 --- a/apps/rush-serve-dashboard/src/modules/phaseLegend.ts +++ b/apps/rush-serve-dashboard/src/modules/phaseLegend.ts @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. import graphStyles from '../styles/graphView.module.css'; +import { getStatusClassName } from './statusHelpers'; interface IPhaseLegendOperation { name: string; @@ -131,7 +132,7 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio if (!summaries.length) { const empty: HTMLDivElement = document.createElement('div'); - empty.className = graphStyles['phase-pane-empty']; + empty.className = graphStyles.phasePaneEmpty; empty.textContent = 'No phases'; options.phaseGroupsEl.appendChild(empty); return; @@ -139,17 +140,17 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio for (const summary of summaries) { const group: HTMLDivElement = document.createElement('div'); - group.className = graphStyles['phase-group']; + group.className = graphStyles.phaseGroup; const header: HTMLDivElement = document.createElement('div'); - header.className = graphStyles['phase-header']; + header.className = graphStyles.phaseHeader; const emoji: HTMLSpanElement = document.createElement('span'); - emoji.className = graphStyles['phase-status-emoji']; + emoji.className = graphStyles.phaseStatusEmoji; emoji.textContent = options.statusEmoji(summary.status); const nameSpan: HTMLSpanElement = document.createElement('span'); - nameSpan.className = graphStyles['phase-name']; + nameSpan.className = graphStyles.phaseName; nameSpan.textContent = summary.phase.replace(/^_phase:/, ''); header.appendChild(emoji); @@ -158,7 +159,7 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio if (summary.problemOps.length) { const list: HTMLUListElement = document.createElement('ul'); - list.className = graphStyles['phase-problems']; + list.className = graphStyles.phaseProblems; const sortedProblems: IPhaseProblemOperation[] = [...summary.problemOps].sort((a, b) => { const ai: number = phaseStatusPriorityIndex.get(a.displayStatus) ?? 999; @@ -175,7 +176,7 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio const item: HTMLLIElement = document.createElement('li'); const status: HTMLSpanElement = document.createElement('span'); - status.className = graphStyles['phase-problem-emoji']; + status.className = graphStyles.phaseProblemEmoji; status.textContent = options.statusEmoji(displayStatus); item.appendChild(status); @@ -266,27 +267,27 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio const statusColors: Record = options.getStatusColors(); const columnsWrap: HTMLDivElement = document.createElement('div'); - columnsWrap.className = graphStyles['legend-columns']; + columnsWrap.className = graphStyles.legendColumns; const colPrimary: HTMLDivElement = document.createElement('div'); - colPrimary.className = graphStyles['legend-col']; + colPrimary.className = graphStyles.legendCol; const primaryHead: HTMLDivElement = document.createElement('div'); - primaryHead.className = graphStyles['legend-heading']; + primaryHead.className = graphStyles.legendHeading; primaryHead.textContent = 'Statuses'; colPrimary.appendChild(primaryHead); for (const status of legendOrder) { const row: HTMLDivElement = document.createElement('div'); - row.className = graphStyles['legend-row']; + row.className = graphStyles.legendRow; const sample: HTMLSpanElement = document.createElement('span'); - sample.className = graphStyles['legend-emoji']; + sample.className = graphStyles.legendEmoji; sample.textContent = options.statusEmoji(status); sample.style.borderColor = statusColors[status] || '#4b5563'; const labelWrap: HTMLDivElement = document.createElement('div'); - labelWrap.className = graphStyles['legend-label-wrap']; + labelWrap.className = graphStyles.legendLabelWrap; const titleSpan: HTMLSpanElement = document.createElement('span'); titleSpan.textContent = options.overallStatusText(status); labelWrap.appendChild(titleSpan); @@ -297,15 +298,15 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio } const unknownRow: HTMLDivElement = document.createElement('div'); - unknownRow.className = graphStyles['legend-row']; + unknownRow.className = graphStyles.legendRow; const unknownSample: HTMLSpanElement = document.createElement('span'); - unknownSample.className = `${graphStyles['legend-emoji']} status-Unknown`; + unknownSample.className = `${graphStyles.legendEmoji} ${getStatusClassName('Unknown')}`; unknownSample.textContent = '❓'; unknownSample.style.borderColor = '#4b5563'; const unknownLabelWrap: HTMLDivElement = document.createElement('div'); - unknownLabelWrap.className = graphStyles['legend-label-wrap']; + unknownLabelWrap.className = graphStyles.legendLabelWrap; const unknownTitle: HTMLSpanElement = document.createElement('span'); unknownTitle.textContent = 'UNKNOWN'; @@ -320,20 +321,20 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio colPrimary.appendChild(unknownRow); const colSecondary: HTMLDivElement = document.createElement('div'); - colSecondary.className = graphStyles['legend-col']; + colSecondary.className = graphStyles.legendCol; const secondaryHead: HTMLDivElement = document.createElement('div'); - secondaryHead.className = graphStyles['legend-heading']; + secondaryHead.className = graphStyles.legendHeading; secondaryHead.textContent = 'State Modifiers'; colSecondary.appendChild(secondaryHead); const addModifier = (sampleFactory: () => HTMLElement, label: string, detail?: string): void => { const row: HTMLDivElement = document.createElement('div'); - row.className = graphStyles['legend-row']; + row.className = graphStyles.legendRow; const sample: HTMLElement = sampleFactory(); const labelWrap: HTMLDivElement = document.createElement('div'); - labelWrap.className = graphStyles['legend-label-wrap']; + labelWrap.className = graphStyles.legendLabelWrap; const titleSpan: HTMLSpanElement = document.createElement('span'); titleSpan.textContent = label; labelWrap.appendChild(titleSpan); @@ -351,7 +352,7 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio const makeNodeBox = (borderStyle?: string, boxShadow?: string, borderColor?: string): HTMLElement => { const sample: HTMLSpanElement = document.createElement('span'); - sample.className = graphStyles['legend-emoji']; + sample.className = graphStyles.legendEmoji; if (borderStyle) sample.style.borderStyle = borderStyle; if (borderColor) sample.style.borderColor = borderColor; if (boxShadow) sample.style.boxShadow = boxShadow; @@ -364,7 +365,7 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio const makeActive = (): HTMLElement => { const wrap: HTMLDivElement = document.createElement('div'); - wrap.className = graphStyles['legend-enabled-sample']; + wrap.className = graphStyles.legendEnabledSample; const base: HTMLSpanElement = document.createElement('span'); base.style.opacity = '0.15'; @@ -386,7 +387,7 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio const makePending = (): HTMLElement => { const wrap: HTMLDivElement = document.createElement('div'); - wrap.className = graphStyles['legend-enabled-sample']; + wrap.className = graphStyles.legendEnabledSample; const base: HTMLSpanElement = document.createElement('span'); base.style.opacity = '0.15'; @@ -408,7 +409,7 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio const makeEnabledSample = (emoji: string): HTMLElement => { const wrap: HTMLDivElement = document.createElement('div'); - wrap.className = graphStyles['legend-enabled-sample']; + wrap.className = graphStyles.legendEnabledSample; const sub: HTMLSpanElement = document.createElement('span'); sub.className = graphStyles.sub; sub.textContent = emoji; @@ -422,7 +423,7 @@ export function createPhaseLegendController(options: IPhaseLegendControllerOptio addModifier(makeDotted, 'Filtered out', 'Hidden by view/search'); const enabledHead: HTMLDivElement = document.createElement('div'); - enabledHead.className = graphStyles['legend-subheading']; + enabledHead.className = graphStyles.legendSubheading; enabledHead.textContent = 'Enabled States'; colSecondary.appendChild(enabledHead); diff --git a/apps/rush-serve-dashboard/src/modules/statusHelpers.ts b/apps/rush-serve-dashboard/src/modules/statusHelpers.ts index 713f89526f1..59135be4d1b 100644 --- a/apps/rush-serve-dashboard/src/modules/statusHelpers.ts +++ b/apps/rush-serve-dashboard/src/modules/statusHelpers.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import globalStyles from '../styles/global.module.css'; + interface IOperationExecutionStateLike { name: string; status?: string; @@ -33,6 +35,29 @@ const statusEmojiMap: Record = { Unknown: '❓' }; +const statusClassNames: Record = { + Ready: globalStyles.statusReady, + Waiting: globalStyles.statusWaiting, + Queued: globalStyles.statusQueued, + Executing: globalStyles.statusExecuting, + Success: globalStyles.statusSuccess, + SuccessWithWarning: globalStyles.statusSuccessWithWarning, + Skipped: globalStyles.statusSkipped, + FromCache: globalStyles.statusFromCache, + Failure: globalStyles.statusFailure, + Blocked: globalStyles.statusBlocked, + NoOp: globalStyles.statusNoOp, + Aborted: globalStyles.statusAborted, + Canceled: globalStyles.statusCanceled, + Unspecified: globalStyles.statusUnspecified, + Unknown: globalStyles.statusUnknown, + Disconnected: globalStyles.statusDisconnected +}; + +export function getStatusClassName(status: string | undefined): string { + return (status && statusClassNames[status]) || globalStyles.statusUnknown; +} + export function statusEmoji(status: string): string { return statusEmojiMap[status] || 'β€’'; } diff --git a/apps/rush-serve-dashboard/src/modules/tableView.ts b/apps/rush-serve-dashboard/src/modules/tableView.ts index 54663af3c9f..0100c0b6d2c 100644 --- a/apps/rush-serve-dashboard/src/modules/tableView.ts +++ b/apps/rush-serve-dashboard/src/modules/tableView.ts @@ -2,6 +2,8 @@ // See LICENSE in the project root for license information. import tableStyles from '../styles/tableView.module.css'; +import globalStyles from '../styles/global.module.css'; +import { getStatusClassName } from './statusHelpers'; interface ITableAnchorCoordinate { row: number; @@ -209,7 +211,7 @@ export function createTableViewController(options: ITableViewControllerOptions): if (allSelected) tr.classList.add(tableStyles.selected); const pkgTd: HTMLTableCellElement = document.createElement('td'); - pkgTd.className = tableStyles['pkg-cell']; + pkgTd.className = tableStyles.pkgCell; pkgTd.textContent = pkg.packageName; pkgTd.style.fontWeight = '600'; pkgTd.style.cursor = 'pointer'; @@ -221,7 +223,7 @@ export function createTableViewController(options: ITableViewControllerOptions): phases.forEach((phase, phaseIndex) => { const td: HTMLTableCellElement = document.createElement('td'); - td.className = tableStyles['pivot-cell']; + td.className = tableStyles.pivotCell; td.style.whiteSpace = 'nowrap'; const op: ITableOperation | undefined = pkg.byPhase.get(phase); @@ -230,12 +232,12 @@ export function createTableViewController(options: ITableViewControllerOptions): 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} + ${escapeHtml(options.overallStatusText(displayStatus))} + ${glyph} ${active} `; td.title = options.buildTooltip(op, displayStatus); diff --git a/apps/rush-serve-dashboard/src/modules/terminalPane.ts b/apps/rush-serve-dashboard/src/modules/terminalPane.ts index 6e127791822..11282c72e8f 100644 --- a/apps/rush-serve-dashboard/src/modules/terminalPane.ts +++ b/apps/rush-serve-dashboard/src/modules/terminalPane.ts @@ -2,6 +2,8 @@ // See LICENSE in the project root for license information. import { AnsiSgrParser } from './ansiSgrParser'; +import globalStyles from '../styles/global.module.css'; +import terminalStyles from '../styles/terminalPane.module.css'; interface ITerminalElementWithState extends HTMLElement { _savedWidth?: number; @@ -50,7 +52,9 @@ function _appendChunk( if (segments && segments.length) { for (const seg of segments) { const span: HTMLSpanElement = document.createElement('span'); - span.className = 'term-chunk ' + (kind === 'stderr' ? 'stderr' : 'stdout'); + span.className = `${terminalStyles.termChunk} ${ + kind === 'stderr' ? terminalStyles.stderr : terminalStyles.stdout + }`; if (seg.style) span.setAttribute('style', seg.style); span.textContent = seg.text; terminalBody.appendChild(span); @@ -61,9 +65,9 @@ function _appendChunk( terminalBody.scrollTop = terminalBody.scrollHeight; } - if (terminalEl && !terminalEl.classList.contains('hidden')) { - terminalEl.classList.add('term-flash'); - setTimeout(() => terminalEl.classList.remove('term-flash'), 350); + if (terminalEl && !terminalEl.classList.contains(terminalStyles.hidden)) { + terminalEl.classList.add(terminalStyles.termFlash); + setTimeout(() => terminalEl.classList.remove(terminalStyles.termFlash), 350); } } @@ -113,7 +117,7 @@ function _wireResizer(terminalEl: HTMLElement | undefined, resizerEl: HTMLElemen newW = Math.max(minW, Math.min(maxW, newW)); terminalWithState._savedWidth = newW; - if (!terminalWithState.classList.contains('hidden')) { + if (!terminalWithState.classList.contains(terminalStyles.hidden)) { terminalWithState.style.width = newW + 'px'; terminalWithState.style.flex = '0 0 ' + newW + 'px'; } @@ -139,7 +143,7 @@ function _wireResizer(terminalEl: HTMLElement | undefined, resizerEl: HTMLElemen else if (e.key === 'ArrowRight') w = Math.min(maxW, w + step); terminalWithState._savedWidth = w; - if (!terminalWithState.classList.contains('hidden')) { + if (!terminalWithState.classList.contains(terminalStyles.hidden)) { terminalWithState.style.width = w + 'px'; terminalWithState.style.flex = '0 0 ' + w + 'px'; } @@ -153,11 +157,11 @@ function _wireToggle(refs: ITerminalPaneRefs): void { const terminalWithState: ITerminalElementWithState = terminalEl as ITerminalElementWithState; toggleTerminalBtn.addEventListener('click', () => { - const currentlyHidden: boolean = terminalWithState.classList.contains('hidden'); + const currentlyHidden: boolean = terminalWithState.classList.contains(terminalStyles.hidden); if (currentlyHidden) { - terminalWithState.classList.remove('hidden'); - if (resizerEl) resizerEl.classList.remove('hidden'); + terminalWithState.classList.remove(terminalStyles.hidden); + if (resizerEl) resizerEl.classList.remove(terminalStyles.hidden); if (terminalWithState._savedWidth) { terminalWithState.style.width = terminalWithState._savedWidth + 'px'; @@ -169,7 +173,7 @@ function _wireToggle(refs: ITerminalPaneRefs): void { if (resizerEl) resizerEl.tabIndex = 0; toggleTerminalBtn.setAttribute('aria-pressed', 'true'); - toggleTerminalBtn.classList.add('active'); + toggleTerminalBtn.classList.add(globalStyles.active); } else { try { terminalWithState._savedWidth = terminalWithState.getBoundingClientRect().width; @@ -177,23 +181,23 @@ function _wireToggle(refs: ITerminalPaneRefs): void { // no-op } - terminalWithState.classList.add('hidden'); + terminalWithState.classList.add(terminalStyles.hidden); if (resizerEl) { - resizerEl.classList.add('hidden'); + resizerEl.classList.add(terminalStyles.hidden); resizerEl.tabIndex = -1; } terminalWithState.style.width = ''; terminalWithState.style.flex = ''; toggleTerminalBtn.setAttribute('aria-pressed', 'false'); - toggleTerminalBtn.classList.remove('active'); + toggleTerminalBtn.classList.remove(globalStyles.active); } }); - const isVisible: boolean = !terminalWithState.classList.contains('hidden'); + const isVisible: boolean = !terminalWithState.classList.contains(terminalStyles.hidden); toggleTerminalBtn.setAttribute('aria-pressed', isVisible ? 'true' : 'false'); - if (isVisible) toggleTerminalBtn.classList.add('active'); - else toggleTerminalBtn.classList.remove('active'); + if (isVisible) toggleTerminalBtn.classList.add(globalStyles.active); + else toggleTerminalBtn.classList.remove(globalStyles.active); if (termAutoscrollBtn && termAutoScrollCheckbox) { termAutoscrollBtn.setAttribute('aria-pressed', termAutoScrollCheckbox.checked ? 'true' : 'false'); diff --git a/apps/rush-serve-dashboard/src/modules/topBar.ts b/apps/rush-serve-dashboard/src/modules/topBar.ts index f7f7fd6a950..92611a5311e 100644 --- a/apps/rush-serve-dashboard/src/modules/topBar.ts +++ b/apps/rush-serve-dashboard/src/modules/topBar.ts @@ -1,6 +1,10 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import globalStyles from '../styles/global.module.css'; +import topBarStyles from '../styles/topBar.module.css'; +import { getStatusClassName } from './statusHelpers'; + export interface ITopBarRefs { connectBtn: HTMLElement | undefined; statusPill: HTMLElement | undefined; @@ -68,7 +72,7 @@ export function showConnectingStatus( ): void { if (!statusPill || !statusEmojiEl) return; - statusPill.className = 'status-pill status-Unspecified'; + statusPill.className = `${globalStyles.statusPill} ${topBarStyles.statusPill} ${getStatusClassName('Unspecified')}`; statusEmojiEl.textContent = statusEmoji('Waiting'); statusPill.textContent = overallStatusText('Connecting'); } @@ -86,8 +90,7 @@ export function updateStatusPill( pillStatus = graphSettings?.status || 'Unspecified'; } - refs.statusPill.className = ''; - refs.statusPill.classList.add('status-pill', 'status-' + pillStatus); + refs.statusPill.className = `${globalStyles.statusPill} ${topBarStyles.statusPill} ${getStatusClassName(pillStatus)}`; refs.statusEmojiEl.textContent = statusEmoji(pillStatus); refs.statusPill.textContent = overallStatusText(pillStatus); } @@ -132,15 +135,15 @@ export function updateManagerState(refs: ITopBarRefs, graphSettings: ITopBarGrap const { debugBtn, verboseBtn, playPauseBtn, parallelismInput, managerStateEl } = refs; if (debugBtn) { - if (graphSettings.debugMode) debugBtn.classList.add('active'); - else debugBtn.classList.remove('active'); + if (graphSettings.debugMode) debugBtn.classList.add(globalStyles.active); + else debugBtn.classList.remove(globalStyles.active); debugBtn.setAttribute('aria-pressed', graphSettings.debugMode ? 'true' : 'false'); debugBtn.title = graphSettings.debugMode ? 'Turn off debug logging' : 'Turn on debug logging'; } if (verboseBtn) { - if (graphSettings.verbose) verboseBtn.classList.add('active'); - else verboseBtn.classList.remove('active'); + if (graphSettings.verbose) verboseBtn.classList.add(globalStyles.active); + else verboseBtn.classList.remove(globalStyles.active); verboseBtn.setAttribute('aria-pressed', graphSettings.verbose ? 'true' : 'false'); verboseBtn.title = graphSettings.verbose ? 'Turn off verbose logging' : 'Turn on verbose logging'; } @@ -149,7 +152,7 @@ export function updateManagerState(refs: ITopBarRefs, graphSettings: ITopBarGrap (playPauseBtn?.querySelector('.codicon') as HTMLElement | null) ?? undefined; if (playPauseBtn) { if (!graphSettings.pauseNextIteration) { - playPauseBtn.classList.add('playing'); + playPauseBtn.classList.add(globalStyles.playing); playPauseBtn.setAttribute('aria-label', 'Switch to manual (pause)'); playPauseBtn.title = 'Pause automatic iterations'; if (ppIcon) { @@ -157,7 +160,7 @@ export function updateManagerState(refs: ITopBarRefs, graphSettings: ITopBarGrap ppIcon.classList.add('codicon-debug-pause'); } } else { - playPauseBtn.classList.remove('playing'); + playPauseBtn.classList.remove(globalStyles.playing); playPauseBtn.setAttribute('aria-label', 'Switch to automatic (play)'); playPauseBtn.title = 'Resume automatic iterations'; if (ppIcon) { @@ -178,11 +181,11 @@ export function updateManagerState(refs: ITopBarRefs, graphSettings: ITopBarGrap const executeBtn: HTMLElement | undefined = document.getElementById('execute-btn') ?? undefined; if (executeBtn) { if (graphSettings.hasScheduledIteration) { - executeBtn.classList.add('queued'); + executeBtn.classList.add(globalStyles.queued); executeBtn.title = 'Run once (changes detected)'; executeBtn.setAttribute('aria-label', 'Run once (changes detected)'); } else { - executeBtn.classList.remove('queued'); + executeBtn.classList.remove(globalStyles.queued); executeBtn.title = 'Run once'; executeBtn.setAttribute('aria-label', 'Run once'); } diff --git a/apps/rush-serve-dashboard/src/styles/global.css b/apps/rush-serve-dashboard/src/styles/global.module.css similarity index 70% rename from apps/rush-serve-dashboard/src/styles/global.css rename to apps/rush-serve-dashboard/src/styles/global.module.css index 06f4b59b167..c8d248f46b6 100644 --- a/apps/rush-serve-dashboard/src/styles/global.css +++ b/apps/rush-serve-dashboard/src/styles/global.module.css @@ -1,11 +1,7 @@ /* Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. */ /* See LICENSE in the project root for license information. */ -@import './top-bar.css'; -@import './selection-bar.css'; -@import './terminal-pane.css'; - -:root { +:global(:root) { --bg: #0f1115; --panel: #1b1f27; --panel-alt: #232a34; @@ -18,13 +14,7 @@ --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; @@ -50,73 +40,57 @@ Arial, sans-serif; } - -html, -body { +:global(html), +:global(body) { height: 100%; margin: 0; background: var(--bg); color: var(--text); } - -body { +.dashboard { display: flex; flex-direction: column; } - -h1 { +.dashboard h1 { font-size: 1.2rem; margin: 0 0 0.5rem; } - -a { +.dashboard a { color: var(--accent); } - -code { +.dashboard code { background: var(--panel-alt); padding: 2px 4px; border-radius: 4px; } - -.flex-spacer { +.flexSpacer { flex: 1 1 auto; } - -#app-title { +.appTitle { font-size: 0.8rem; font-weight: 600; letter-spacing: 0.5px; min-width: 180px; } - -#actions { +.actions, +.graphState { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; } - -#graph-state { - display: flex; - gap: 0.4rem; - flex-wrap: wrap; -} - -.parallelism-label { +.parallelismLabel { font-size: 0.65rem; align-items: baseline; gap: 4px; } - -.search-label { +.searchLabel { display: flex; align-items: center; gap: 4px; } - -#parallelism-input, -#name-search { +.parallelismInput, +.nameSearch { background: var(--panel-alt); border: 1px solid var(--border); color: var(--text); @@ -124,48 +98,39 @@ code { padding: 2px 4px; font-size: 0.7rem; } - -#parallelism-input { +.parallelismInput { width: 60px; } - -#name-search { +.nameSearch { min-width: 140px; } - -#connection-form { +.connectionForm { gap: 0.4rem; } - -.content-wrap { +.contentWrap { display: flex; flex: 1; min-height: 0; overflow: hidden; } - -#main { +.main { flex: 1; display: flex; min-height: 0; } - -#view-controls { +.viewControls { display: flex; gap: 0.75rem; align-items: center; font-size: 0.7rem; } - -#view-controls label { +.viewControls label { display: flex; gap: 0.25rem; align-items: center; cursor: pointer; } - -select, -#filter-select { +.dashboard select { background: var(--panel-alt); color: var(--text); border: 1px solid var(--border); @@ -173,43 +138,24 @@ select, padding: 2px 4px; font-size: 0.7rem; } - -select:disabled { +.dashboard select:disabled { opacity: 0.6; } - -#connection-form input { +.connectionForm 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; +.dashboard button:not(:disabled) { cursor: pointer; } - -#connection-form button.icon-btn { - background: transparent; - border: none; - color: inherit; - padding: 0; -} - -button:not(:disabled) { - cursor: pointer; -} - -button:disabled { +.dashboard button:disabled { opacity: 0.5; cursor: not-allowed; } - -button.action { +.action { background: var(--panel-alt); color: var(--text); border: 1px solid var(--border); @@ -217,24 +163,20 @@ button.action { border-radius: var(--radius); font-size: 0.8rem; } - -button.action:hover:not(:disabled) { +.action:hover:not(:disabled) { background: #2d3744; } - -button.primary, -.icon-btn.primary { +.primary, +.iconBtn.primary { background: var(--accent); border-color: var(--accent); color: #fff; } - -button.primary:hover:not(:disabled), -.icon-btn.primary:hover:not(:disabled) { +.primary:hover:not(:disabled), +.iconBtn.primary:hover:not(:disabled) { background: var(--accent-hover); } - -.icon-btn { +.iconBtn { display: inline-flex; align-items: center; justify-content: center; @@ -248,39 +190,29 @@ button.primary:hover:not(:disabled), color: var(--success); position: relative; } - -.icon-btn:hover:not(:disabled) { +.iconBtn:hover:not(:disabled), +.iconBtn.stop:hover:not(:disabled) { filter: brightness(1.15); } - -.icon-btn.stop { +.iconBtn.stop { color: var(--danger); } - -.icon-btn.stop:hover:not(:disabled) { - filter: brightness(1.15); -} - -.icon-btn.playing { +.iconBtn.playing { color: var(--accent); } - -.icon-btn.playing:hover:not(:disabled) { +.iconBtn.playing:hover:not(:disabled) { color: var(--accent-hover); filter: none; } - -.icon-btn:disabled { +.iconBtn:disabled { opacity: 0.45; } - -.icon-btn:focus-visible { +.iconBtn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; } - -.icon-btn.queued::after { +.iconBtn.queued::after { content: ''; position: absolute; width: 18px; @@ -290,88 +222,67 @@ button.primary:hover:not(:disabled), animation: queuedPulse 1.2s linear infinite; pointer-events: none; } - -.icon-btn.toggle { +.iconBtn.toggle { color: var(--muted); } - -.icon-btn.toggle:hover:not(.active):not(:disabled) { +.iconBtn.toggle:hover:not(.active):not(:disabled) { color: var(--text); } - -.icon-btn.toggle.active { +.iconBtn.toggle.active, +.iconBtn.active { color: var(--accent); } - -.icon-btn.toggle.active:hover:not(:disabled) { +.iconBtn.toggle.active:hover:not(:disabled) { color: var(--accent-hover); } - @keyframes queuedPulse { - 0% { + 0%, + 100% { opacity: 0.2; } 50% { opacity: 0.9; } - 100% { - opacity: 0.2; - } } - -.codicon { +.dashboard :global(.codicon) { font-size: 18px; line-height: 1; } - -.icon-btn .codicon { +.iconBtn :global(.codicon) { pointer-events: none; } - -.icon-btn.stop .codicon { +.iconBtn.stop :global(.codicon) { font-size: 17px; } - -.icon-btn.queued .icon { - transform: scale(1.05); -} - -button.danger { +.danger { background: var(--danger); border-color: var(--danger); color: #fff; } - -button.danger:hover:not(:disabled) { +.danger:hover:not(:disabled) { filter: brightness(1.1); } - -#left, -#right { +.pane { flex: 1; display: flex; flex-direction: column; min-height: 0; } - -#left { +.leftPane { border-right: 1px solid var(--border); } - .section { padding: 0.75rem 0.75rem 0.25rem; background: var(--panel); border-bottom: 1px solid var(--border); } - .section h2 { margin: 0 0 0.5rem; font-size: 0.95rem; letter-spacing: 0.5px; font-weight: 600; } - -.status-pill { +.statusPill { padding: 2px 6px; border-radius: 8px; font-weight: 600; @@ -383,77 +294,56 @@ button.danger:hover:not(:disabled) { text-align: center; box-sizing: border-box; } - -.status-Ready { +.statusReady, +.statusUnspecified, +.statusUnknown { background: var(--status-ready); } - -.status-Waiting { +.statusWaiting { background: var(--status-waiting); } - -.status-Queued { +.statusQueued { background: var(--status-queued); } - -.status-Executing { +.statusExecuting { background: var(--warn); color: #000; } - -.status-Success { +.statusSuccess { background: var(--success); color: #000; } - -.status-SuccessWithWarning { +.statusSuccessWithWarning { background: var(--status-success-warning); color: #000; } - -.status-FromCache { +.statusFromCache { background: var(--status-from-cache); color: #000; } - -.status-Failure { +.statusFailure { background: var(--danger); } - -.status-Blocked, -.status-Skipped { +.statusBlocked, +.statusSkipped { background: var(--status-skipped); } - -.status-NoOp { +.statusNoOp { background: var(--status-noop); } - -.status-Aborted { - background: var(--status-aborted); -} - -.status-Canceled { +.statusAborted, +.statusCanceled { background: var(--status-aborted); } - -.status-Unspecified { - background: var(--status-ready); -} - -.status-Unknown { - background: var(--status-ready); +.statusUnknown { opacity: 0.85; } - -.status-Disconnected { +.statusDisconnected { background: var(--status-disconnected); } - .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; } - .badge { background: #334155; padding: 2px 4px; @@ -463,30 +353,24 @@ button.danger:hover:not(:disabled) { letter-spacing: 0.5px; font-weight: 600; } - .badge.silent { background: #4b5563; } - .badge.active { background: var(--accent); } - .badge.disabled { background: #64748b; } - -.badge.local-only { +.badge.localOnly { background: var(--warn); color: #000; } - -.badge.not-running { +.badge.notRunning { background: #1e293b; border: 1px dashed #475569; } - -#bottom-bar { +.bottomBar { background: var(--panel); border-top: 1px solid var(--border); padding: 0.3rem 0.6rem; @@ -495,63 +379,47 @@ button.danger:hover:not(:disabled) { font-size: 0.6rem; align-items: center; } - .pill { background: var(--panel-alt); padding: 2px 6px; border-radius: 12px; } - -.flex-row { +.flexRow { display: flex; gap: 0.4rem; align-items: center; } - -input[type='number'] { +.dashboard input[type='number'] { width: 70px; } - -.icon-btn.active, -.icon-btn.toggle.active { - color: var(--accent); -} - @media (max-width: 1200px) { - #main { + .main { flex-direction: column; } - - #left { + .leftPane { border-right: none; border-bottom: 1px solid var(--border); } } - -* { +.dashboard * { scrollbar-color: var(--scroll-thumb) var(--scroll-track); scrollbar-width: thin; } - -::-webkit-scrollbar { +.dashboard *::-webkit-scrollbar { width: 10px; height: 10px; } - -::-webkit-scrollbar-track { +.dashboard *::-webkit-scrollbar-track { background: var(--scroll-track); } - -::-webkit-scrollbar-thumb { +.dashboard *::-webkit-scrollbar-thumb { background: var(--scroll-thumb); border: 2px solid var(--scroll-track); border-radius: 8px; } - -::-webkit-scrollbar-thumb:hover { +.dashboard *::-webkit-scrollbar-thumb:hover { background: var(--scroll-thumb-hover); } - -::-webkit-scrollbar-corner { +.dashboard *::-webkit-scrollbar-corner { background: var(--scroll-track); } diff --git a/apps/rush-serve-dashboard/src/styles/graphView.module.css b/apps/rush-serve-dashboard/src/styles/graphView.module.css index 57ddff563ca..0aaa61d566f 100644 --- a/apps/rush-serve-dashboard/src/styles/graphView.module.css +++ b/apps/rush-serve-dashboard/src/styles/graphView.module.css @@ -1,7 +1,7 @@ /* Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. */ /* See LICENSE in the project root for license information. */ -:global(#graph-container) { +.graphContainer { position: relative; flex: 1; min-height: 0; @@ -11,7 +11,7 @@ gap: 0; } -:global(#graph) { +.graph { width: 100%; height: 100%; position: relative; @@ -19,7 +19,7 @@ background: radial-gradient(circle at 25% 20%, #1e2530, #101318); } -:global(#phase-pane) { +.phasePane { width: 220px; background: var(--panel); border-right: 1px solid var(--border); @@ -30,7 +30,7 @@ position: relative; } -:global(#phase-pane) h3 { +.phasePane h3 { margin: 0 0 0.4rem; font-size: 0.68rem; letter-spacing: 0.5px; @@ -38,7 +38,7 @@ opacity: 0.8; } -.phase-group { +.phaseGroup { padding: 0.4rem 0.45rem 0.45rem; background: var(--panel-alt); border: 1px solid var(--border); @@ -48,7 +48,7 @@ min-height: 0; } -:global(#phase-groups) { +.phaseGroups { display: flex; flex-direction: column; gap: 0.6rem; @@ -56,11 +56,11 @@ overflow: hidden; } -:global(#phase-groups) > .phase-group { +.phaseGroups > .phaseGroup { flex: 1 1 0; } -.phase-header { +.phaseHeader { display: flex; align-items: center; gap: 0.35rem; @@ -73,16 +73,16 @@ border-radius: 4px; } -.phase-status-emoji { +.phaseStatusEmoji { font-size: 0.8rem; line-height: 1; } -.phase-name { +.phaseName { flex: 1; } -.phase-problems { +.phaseProblems { list-style: none; margin: 0; padding: 0; @@ -94,38 +94,38 @@ min-height: 0; } -.phase-problems li { +.phaseProblems li { display: flex; align-items: center; gap: 0.35rem; } -.phase-problems a { +.phaseProblems a { color: var(--accent); text-decoration: none; } -.phase-problems a:hover { +.phaseProblems a:hover { text-decoration: underline; } -.phase-problem-emoji { +.phaseProblemEmoji { font-size: 0.75rem; } -.phase-pane-empty { +.phasePaneEmpty { opacity: 0.6; font-style: italic; font-size: 0.6rem; } -:global(#graph-wrapper) { +.graphWrapper { flex: 1; min-height: 0; position: relative; } -:global(#graph-legend) { +.graphLegend { position: absolute; right: 28px; bottom: 28px; @@ -140,31 +140,31 @@ pointer-events: auto; } -:global(#graph-legend) .legend-columns { +.graphLegend .legendColumns { display: flex; gap: 10px; align-items: flex-start; } -:global(#graph-legend) .legend-col:first-child { +.graphLegend .legendCol:first-child { flex: 0 0 130px; } -:global(#graph-legend) .legend-col:last-child { +.graphLegend .legendCol:last-child { flex: 1 1 auto; } -:global(#graph-legend).collapsed { +.graphLegend.collapsed { width: auto; max-width: none; padding: 4px 6px; } -:global(#graph-legend).collapsed .legend-columns { +.graphLegend.collapsed .legendColumns { display: none; } -:global(#graph-legend) .legend-col { +.graphLegend .legendCol { flex: 1 1 0; min-width: 0; display: flex; @@ -172,7 +172,7 @@ gap: 2px; } -:global(#graph-legend) .legend-heading { +.graphLegend .legendHeading { font-weight: 600; letter-spacing: 0.5px; text-transform: uppercase; @@ -181,26 +181,26 @@ margin: 0 0 4px; } -:global(#graph-legend) .legend-row { +.graphLegend .legendRow { display: flex; align-items: center; gap: 5px; min-width: 0; } -:global(#graph-legend) .legend-label-wrap { +.graphLegend .legendLabelWrap { display: flex; flex-direction: column; gap: 2px; min-width: 0; } -:global(#graph-legend) .legend-row small { +.graphLegend .legendRow small { font-size: 0.5rem; opacity: 0.7; } -:global(#graph-legend) .legend-subheading { +.graphLegend .legendSubheading { margin: 4px 0 2px; font-size: 0.52rem; text-transform: uppercase; @@ -209,7 +209,7 @@ font-weight: 600; } -:global(#graph-legend) h4 { +.graphLegend h4 { margin: 0 0 4px; font-size: 0.6rem; letter-spacing: 0.6px; @@ -220,7 +220,7 @@ gap: 4px; } -.legend-emoji { +.legendEmoji { font-size: 0.7rem; width: 18px; height: 18px; @@ -233,7 +233,7 @@ box-sizing: border-box; } -.legend-enabled-sample { +.legendEnabledSample { position: relative; width: 18px; height: 18px; @@ -247,7 +247,7 @@ box-sizing: border-box; } -.legend-enabled-sample .sub { +.legendEnabledSample .sub { position: absolute; bottom: 0; right: 0; @@ -258,13 +258,13 @@ pointer-events: none; } -.legend-label { +.legendLabel { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -:global(#graph) svg { +.graph svg { position: absolute; top: 0; left: 0; @@ -272,7 +272,7 @@ pointer-events: none; } -.graph-marquee { +.graphMarquee { position: absolute; border: 1px solid var(--accent); background: rgba(var(--rgb-accent), 0.15); @@ -281,7 +281,7 @@ box-shadow: 0 0 0 1px rgba(var(--rgb-accent), 0.4) inset; } -.op-node { +.opNode { position: absolute; width: 28px; height: 28px; @@ -303,7 +303,7 @@ transform 0.05s ease; } -.op-node .enabled-indicator { +.opNode .enabledIndicator { position: absolute; bottom: 0; right: 0; @@ -315,15 +315,15 @@ transition: opacity 0.15s ease; } -.op-node.selected { +.opNode.selected { box-shadow: 0 0 0 2px var(--accent); } -.op-node:hover { +.opNode:hover { transform: scale(1.08); } -.op-node .active-indicator { +.opNode .activeIndicator { position: absolute; bottom: 0; left: 0; @@ -334,7 +334,7 @@ filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.55)); } -.op-node .pending-indicator { +.opNode .pendingIndicator { position: absolute; top: 0; left: 0; @@ -345,73 +345,73 @@ 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 { +.opNode.filteredOut .activeIndicator, +.opNode.filteredOutSearch .activeIndicator, +.opNode.notRunning .activeIndicator, +.opNode.filteredOut.notRunning .activeIndicator { opacity: 1 !important; } -.op-node .name { +.opNode .name { font-weight: 600; } -.op-node .pkg { +.opNode .pkg { color: var(--muted); font-size: 0.6rem; } -.op-node .badges { +.opNode .badges { display: flex; gap: 4px; flex-wrap: wrap; } -.op-node .enabled-indicator { +.opNode .enabledIndicator { opacity: 1; } -.op-node.not-running .enabled-indicator { +.opNode.notRunning .enabledIndicator { opacity: 0.5; } -.op-node.filtered-out .enabled-indicator { +.opNode.filteredOut .enabledIndicator { opacity: 0.25; } -.op-node.filtered-out-search .enabled-indicator { +.opNode.filteredOutSearch .enabledIndicator { opacity: 0.15; } -.op-node.filtered-out.not-running .enabled-indicator { +.opNode.filteredOut.notRunning .enabledIndicator { opacity: 0.25; } -.op-node .emoji { +.opNode .emoji { transition: opacity 0.15s ease; } -.op-node.filtered-out .emoji { +.opNode.filteredOut .emoji { opacity: 0.25; } -.op-node.filtered-out-search .emoji { +.opNode.filteredOutSearch .emoji { opacity: 0.15; } -.op-node.not-running .emoji { +.opNode.notRunning .emoji { opacity: 0.5; } -.op-node.filtered-out.not-running .emoji { +.opNode.filteredOut.notRunning .emoji { opacity: 0.25; } -.op-node.dashed { +.opNode.dashed { border-style: dashed; } -.op-node.dotted { +.opNode.dotted { border-style: dotted; } @@ -428,11 +428,11 @@ stroke-dasharray: 2 4; } -.edge.filtered-out { +.edge.filteredOut { opacity: 0.35; } -.edge.filtered-out-search { +.edge.filteredOutSearch { opacity: 0.2; } @@ -446,6 +446,6 @@ filter: brightness(0.8); } -.edge.not-running { +.edge.notRunning { opacity: 0.55; } diff --git a/apps/rush-serve-dashboard/src/styles/selection-bar.css b/apps/rush-serve-dashboard/src/styles/selectionBar.module.css similarity index 69% rename from apps/rush-serve-dashboard/src/styles/selection-bar.css rename to apps/rush-serve-dashboard/src/styles/selectionBar.module.css index 71253cf0914..8d2250f3445 100644 --- a/apps/rush-serve-dashboard/src/styles/selection-bar.css +++ b/apps/rush-serve-dashboard/src/styles/selectionBar.module.css @@ -1,7 +1,7 @@ /* Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. */ /* See LICENSE in the project root for license information. */ -#selection-heading { +.selectionHeading { font-weight: 600; letter-spacing: 0.5px; font-size: 0.7rem; @@ -9,16 +9,13 @@ align-items: center; gap: 0.5rem; } - -#selection-actions { +.selectionActions { display: flex; gap: 0.4rem; flex-wrap: wrap; align-items: center; } - -/* Selection bar (hidden when no selection) */ -#selection-bar { +.selectionBar { background: var(--panel-alt); border-bottom: 1px solid var(--border); padding: 0.35rem 0.6rem; @@ -28,29 +25,21 @@ align-items: center; flex-wrap: wrap; } - -#selection-actions button.action { +.selectionActions button { font-size: 0.65rem; } - -#selection-bar .action { +.selectionBar button { 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] { +.selectionCountButton[disabled] { opacity: 0.6; cursor: default; } - -#selection-bar[aria-hidden='true'] { +.selectionBar[aria-hidden='true'] { display: none; } diff --git a/apps/rush-serve-dashboard/src/styles/tableView.module.css b/apps/rush-serve-dashboard/src/styles/tableView.module.css index 1ce80f20621..3eff12a282a 100644 --- a/apps/rush-serve-dashboard/src/styles/tableView.module.css +++ b/apps/rush-serve-dashboard/src/styles/tableView.module.css @@ -1,105 +1,105 @@ /* Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. */ /* See LICENSE in the project root for license information. */ -:global(#operations-table-container) { +.operationsTableContainer { flex: 1; min-height: 0; overflow: auto; } -table { +.operationsTable { width: 100%; border-collapse: collapse; font-size: 0.75rem; } -thead { +.operationsTable thead { position: sticky; top: 0; background: var(--panel-alt); z-index: 2; } -th, -td { +.operationsTable th, +.operationsTable td { padding: 0.35rem 0.5rem; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; } -tbody tr { +.operationsTable tbody tr { cursor: pointer; } -tbody tr.selected { +.operationsTable tbody tr.selected { background: rgba(var(--rgb-accent), 0.15); } -tbody tr:hover { +.operationsTable tbody tr:hover { background: rgba(var(--rgb-white), 0.05); } -td.pivot-cell.selected { +.operationsTable td.pivotCell.selected { background: rgba(var(--rgb-accent), 0.22); } -tr.selected > td.pkg-cell { +.operationsTable tr.selected > td.pkgCell { background: rgba(var(--rgb-accent), 0.18); } /* Prevent brief native text highlight flash during selection interactions */ -td.pivot-cell, -td.pkg-cell { +.operationsTable td.pivotCell, +.operationsTable td.pkgCell { -webkit-user-select: none; -moz-user-select: none; user-select: none; } -th.sortable { +.operationsTable th.sortable { user-select: none; } -th.sortable span.sort-indicator { +.operationsTable th.sortable span.sortIndicator { opacity: 0.5; margin-left: 4px; } -.status-row { +.statusRow { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; } -.status-row .badges { +.statusRow .badges { display: flex; gap: 4px; flex-wrap: wrap; margin-left: 4px; } -td.status-cell { +.operationsTable td.statusCell { white-space: nowrap; } -td.status-cell .status-pill { +.operationsTable td.statusCell .statusPill { margin-left: 4px; } -.pivot-enabled { +.pivotEnabled { margin-left: 4px; font-size: 0.75rem; } -.pivot-active { +.pivotActive { margin-left: 4px; font-size: 0.75rem; position: relative; top: 1px; } -:global(#table-stats) { +.tableStats { font-size: 0.6rem; color: var(--muted); padding: 0.25rem 0.75rem 0.5rem; diff --git a/apps/rush-serve-dashboard/src/styles/terminal-pane.css b/apps/rush-serve-dashboard/src/styles/terminalPane.module.css similarity index 73% rename from apps/rush-serve-dashboard/src/styles/terminal-pane.css rename to apps/rush-serve-dashboard/src/styles/terminalPane.module.css index 3c80f1561c6..24908205c54 100644 --- a/apps/rush-serve-dashboard/src/styles/terminal-pane.css +++ b/apps/rush-serve-dashboard/src/styles/terminalPane.module.css @@ -1,22 +1,19 @@ /* Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. */ /* See LICENSE in the project root for license information. */ -#resizer { +.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 { +.resizer:hover { background: linear-gradient(90deg, transparent, rgba(var(--rgb-accent), 0.12), transparent); } - -#term-autoscroll { +.termAutoscroll { display: none; } - -.terminal-container { +.terminalContainer { background: #000; border-left: 1px solid var(--border); color: var(--text); @@ -31,36 +28,28 @@ width 0.18s ease, box-shadow 0.18s ease; } - -.terminal-header { +.terminalHeader { display: flex; align-items: center; gap: 0.5rem; padding: 6px 8px; border-bottom: 1px solid var(--border); } - -.terminal-title { +.terminalTitle { font-weight: 600; font-size: 0.8rem; } - -.terminal-controls { +.terminalControls { margin-left: auto; display: flex; gap: 6px; align-items: center; } - -.terminal-container.hidden { +.terminalContainer.hidden, +.resizer.hidden { display: none !important; } - -#resizer.hidden { - display: none !important; -} - -.terminal-body { +.terminalBody { flex: 1 1 auto; padding: 8px; overflow: auto; @@ -69,45 +58,35 @@ font-size: 12px; line-height: 1.25; } - -.term-chunk.stdout { +.termChunk.stdout { color: var(--text); } - -.term-chunk.stderr { +.termChunk.stderr { color: var(--danger); } - -.terminal-controls button.icon-btn { +.terminalControls button { width: auto; height: auto; padding: 4px 8px; } - -.terminal-container.term-flash .terminal-header { +.terminalContainer.termFlash .terminalHeader { 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 { +.toggleTerminalButton[aria-pressed='true'], +.toggleTerminalButton[aria-pressed='true'] :global(.codicon) { color: var(--accent) !important; fill: var(--accent) !important; } - -.codicon.vertical { +.vertical { display: inline-block; transform: rotate(90deg); } - @keyframes termFlash { - 0% { + 0%, + 100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); } 30% { box-shadow: 0 0 8px 2px rgba(59, 130, 246, 0.18); } - 100% { - box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); - } } diff --git a/apps/rush-serve-dashboard/src/styles/top-bar.css b/apps/rush-serve-dashboard/src/styles/topBar.module.css similarity index 67% rename from apps/rush-serve-dashboard/src/styles/top-bar.css rename to apps/rush-serve-dashboard/src/styles/topBar.module.css index e8ad859010d..e60eb9bd759 100644 --- a/apps/rush-serve-dashboard/src/styles/top-bar.css +++ b/apps/rush-serve-dashboard/src/styles/topBar.module.css @@ -1,8 +1,7 @@ /* 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 { +.topBar { display: flex; flex-wrap: wrap; gap: 0.5rem 0.75rem; @@ -13,13 +12,11 @@ max-height: 40vh; overflow-y: auto; } - -#top-bar > * { +.topBar > * { display: flex; align-items: baseline; } - -#top-bar .icon-btn { +.topBar button { width: auto; height: auto; padding: 0 4px; @@ -27,27 +24,22 @@ line-height: 1.05; align-items: baseline; } - -#top-bar .icon-btn .codicon, -#top-bar .icon-btn span[aria-hidden='true'] { +.topBar button :global(.codicon), +.topBar button 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 { +.actions, +.graphState, +.viewControls { align-items: baseline; } - -#top-bar #overall-status { +.overallStatus { display: flex; align-items: center; } - -#status-emoji { +.statusEmoji { position: relative; top: 1px; font-size: 1rem; @@ -58,33 +50,25 @@ width: 20px; height: 20px; } - -#status-pill { +.statusPill { 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'] { +.connectionForm .connectButton[data-state='connected'] { color: var(--danger); } - -/* Disconnected state = accent (blue) foreground */ -#top-bar #connection-form #connect-btn[data-state='disconnected'] { +.connectionForm .connectButton[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 { +.topBar input[type='number']::-webkit-inner-spin-button, +.topBar input[type='number']::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } - -#top-bar input[type='number'] { +.topBar input[type='number'] { background-image: linear-gradient(135deg, var(--muted) 50%, transparent 50%), linear-gradient(315deg, var(--muted) 50%, transparent 50%), @@ -105,8 +89,7 @@ appearance: textfield; -moz-appearance: textfield; } - -#top-bar input[type='number']:focus { +.topBar input[type='number']:focus { outline: 1px solid var(--accent); outline-offset: 0; } diff --git a/apps/rush-serve-dashboard/src/test/actionWiring.test.ts b/apps/rush-serve-dashboard/src/test/actionWiring.test.ts index 024cebe796e..27d3559bbc3 100644 --- a/apps/rush-serve-dashboard/src/test/actionWiring.test.ts +++ b/apps/rush-serve-dashboard/src/test/actionWiring.test.ts @@ -4,6 +4,7 @@ import { wireLeftBarActions } from '../modules/leftBar'; import { wireMainBarActions } from '../modules/mainBar'; import { createSelectionBarController } from '../modules/selectionBar'; +import globalStyles from '../styles/global.module.css'; import { computeWsUrl, overallStatusText, @@ -152,7 +153,7 @@ describe('top and selection bars', () => { expect(refs.statusPill?.textContent).toBe('CONNECTING'); const socket: WebSocket = { readyState: WebSocket.OPEN } as WebSocket; updateStatusPill(refs, socket, { status: 'Success' }, () => 'success'); - expect(refs.statusPill?.classList.contains('status-Success')).toBe(true); + expect(refs.statusPill?.classList.contains(globalStyles.statusSuccess)).toBe(true); const updateSelectionUI = jest.fn(); setConnected(refs, true, updateSelectionUI, ['execute-btn']); @@ -173,7 +174,7 @@ describe('top and selection bars', () => { expect(refs.debugBtn?.getAttribute('aria-pressed')).toBe('true'); expect(refs.playPauseBtn?.title).toBe('Resume automatic iterations'); expect(refs.parallelismInput?.value).toBe('8'); - expect(document.getElementById('execute-btn')?.classList.contains('queued')).toBe(true); + expect(document.getElementById('execute-btn')?.classList.contains(globalStyles.queued)).toBe(true); const controller = createSelectionBarController({ getSelection: () => new Set(['build']), diff --git a/apps/rush-serve-dashboard/src/test/dashboard.test.ts b/apps/rush-serve-dashboard/src/test/dashboard.test.ts index 5731fbecf4b..7721c618b33 100644 --- a/apps/rush-serve-dashboard/src/test/dashboard.test.ts +++ b/apps/rush-serve-dashboard/src/test/dashboard.test.ts @@ -30,7 +30,7 @@ describe('dashboard entrypoint', () => {
-
+
diff --git a/apps/rush-serve-dashboard/src/test/graphSelection.test.ts b/apps/rush-serve-dashboard/src/test/graphSelection.test.ts index 7e2293fc42f..9cae50407dc 100644 --- a/apps/rush-serve-dashboard/src/test/graphSelection.test.ts +++ b/apps/rush-serve-dashboard/src/test/graphSelection.test.ts @@ -68,6 +68,6 @@ describe('graph selection controller', () => { expect(selection).toEqual(new Set(['inside'])); expect(onLiveSelectionChanged).toHaveBeenCalled(); - expect(graphEl.querySelector(`.${graphStyles['graph-marquee']}`)).toBeNull(); + expect(graphEl.querySelector(`.${graphStyles.graphMarquee}`)).toBeNull(); }); }); diff --git a/apps/rush-serve-dashboard/src/test/graphView.test.ts b/apps/rush-serve-dashboard/src/test/graphView.test.ts index feaeccc4339..a14815965a1 100644 --- a/apps/rush-serve-dashboard/src/test/graphView.test.ts +++ b/apps/rush-serve-dashboard/src/test/graphView.test.ts @@ -48,13 +48,13 @@ describe('graph view controller', () => { 'test->build' ]); expect( - graphState.nodeElements.get('build')?.querySelector(`.${graphStyles['active-indicator']}`) + graphState.nodeElements.get('build')?.querySelector(`.${graphStyles.activeIndicator}`) ).not.toBeNull(); expect( - graphState.nodeElements.get('test')?.querySelector(`.${graphStyles['pending-indicator']}`) + graphState.nodeElements.get('test')?.querySelector(`.${graphStyles.pendingIndicator}`) ).not.toBeNull(); expect( - graphState.nodeElements.get('test')?.querySelector(`.${graphStyles['enabled-indicator']}`)?.textContent + graphState.nodeElements.get('test')?.querySelector(`.${graphStyles.enabledIndicator}`)?.textContent ).toBe('πŸ”΄'); expect(renderPhaseLegend).toHaveBeenCalled(); diff --git a/apps/rush-serve-dashboard/src/test/outputPanels.test.ts b/apps/rush-serve-dashboard/src/test/outputPanels.test.ts index 0ce1e5ad2c8..2192a3d2178 100644 --- a/apps/rush-serve-dashboard/src/test/outputPanels.test.ts +++ b/apps/rush-serve-dashboard/src/test/outputPanels.test.ts @@ -3,6 +3,8 @@ import { createPhaseLegendController } from '../modules/phaseLegend'; import { createTerminalPaneController } from '../modules/terminalPane'; +import graphStyles from '../styles/graphView.module.css'; +import terminalStyles from '../styles/terminalPane.module.css'; describe('phase and legend controller', () => { beforeEach(() => { @@ -49,7 +51,7 @@ describe('phase and legend controller', () => { 'legend-collapse-btn' ) as HTMLButtonElement; collapseButton.click(); - expect(document.getElementById('legend')?.classList.contains('collapsed')).toBe(true); + expect(document.getElementById('legend')?.classList.contains(graphStyles.collapsed)).toBe(true); expect(window.localStorage.getItem('rushServeLegendCollapsed')).toBe('1'); }); }); @@ -82,18 +84,18 @@ describe('terminal pane controller', () => { }); controller.appendChunk('stderr', '\u001b[31mfailed'); - const chunk: HTMLElement = terminalBody.querySelector('.term-chunk') as HTMLElement; + const chunk: HTMLElement = terminalBody.querySelector(`.${terminalStyles.termChunk}`) as HTMLElement; expect(chunk.textContent).toBe('failed'); - expect(chunk.classList.contains('stderr')).toBe(true); + expect(chunk.classList.contains(terminalStyles.stderr)).toBe(true); expect(chunk.style.color).toBe('rgb(170, 0, 0)'); - expect(terminalEl.classList.contains('term-flash')).toBe(true); + expect(terminalEl.classList.contains(terminalStyles.termFlash)).toBe(true); jest.advanceTimersByTime(350); - expect(terminalEl.classList.contains('term-flash')).toBe(false); + expect(terminalEl.classList.contains(terminalStyles.termFlash)).toBe(false); document.getElementById('autoscroll')?.click(); expect((document.getElementById('autoscroll-checkbox') as HTMLInputElement).checked).toBe(false); document.getElementById('toggle')?.click(); - expect(terminalEl.classList.contains('hidden')).toBe(true); + expect(terminalEl.classList.contains(terminalStyles.hidden)).toBe(true); document.getElementById('clear')?.click(); expect(terminalBody.textContent).toBe(''); }); diff --git a/apps/rush-serve-dashboard/src/test/tableView.test.ts b/apps/rush-serve-dashboard/src/test/tableView.test.ts index a304e1940e7..afae8a9ecde 100644 --- a/apps/rush-serve-dashboard/src/test/tableView.test.ts +++ b/apps/rush-serve-dashboard/src/test/tableView.test.ts @@ -39,12 +39,12 @@ describe('table view controller', () => { expect(document.getElementById('stats')?.textContent).toBe('3 operations'); const firstOperationCell = document.querySelector( - `td.${tableStyles['pivot-cell']}[title="a-build"]` + `td.${tableStyles.pivotCell}[title="a-build"]` ) as HTMLElement; firstOperationCell.click(); expect(selection).toEqual(new Set(['a-build'])); - const firstPackageCell = document.querySelector(`.${tableStyles['pkg-cell']}`) as HTMLElement; + const firstPackageCell = document.querySelector(`.${tableStyles.pkgCell}`) as HTMLElement; firstPackageCell.click(); expect(selection).toEqual(new Set(['a-build', 'a-test'])); expect(onSelectionMutated).toHaveBeenCalledTimes(2); diff --git a/rush-plugins/rush-serve-plugin/webpack.config.js b/rush-plugins/rush-serve-plugin/webpack.config.js index 42fc30beffe..fb83bfd350d 100644 --- a/rush-plugins/rush-serve-plugin/webpack.config.js +++ b/rush-plugins/rush-serve-plugin/webpack.config.js @@ -41,7 +41,7 @@ function createWebpackConfig({ production }) { filename: 'dashboard.html', inject: 'body', scriptLoading: 'module', - template: path.join(dashboardPackageFolder, 'lib-esm', 'dashboard.html') + template: path.join(dashboardPackageFolder, 'assets', 'dashboard.html') }) ] }));