From e723169fe2a911237ed512df55668dbf9b6b3b46 Mon Sep 17 00:00:00 2001 From: Mo Jazayeri Date: Wed, 22 Jul 2026 02:06:35 +0000 Subject: [PATCH 1/2] 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 2/2] 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 }