From 13566b2c5376efd752142aeb4a8e97f610a4fbb4 Mon Sep 17 00:00:00 2001 From: max313iq Date: Fri, 20 Mar 2026 01:14:57 +0300 Subject: [PATCH 1/7] Add subagent exploration logs and repo probe snapshots --- .codex-subagents/agent01-structure.log | 19 +++++++++++++++++++ .codex-subagents/agent02-root-tooling.log | 18 ++++++++++++++++++ .codex-subagents/agent03-desktop.log | 18 ++++++++++++++++++ .codex-subagents/agent04-web.log | 18 ++++++++++++++++++ .codex-subagents/agent05-packages.log | 18 ++++++++++++++++++ .codex-subagents/agent06-util.log | 18 ++++++++++++++++++ .codex-subagents/agent07-docs.log | 18 ++++++++++++++++++ .../agent08-dependency-boundaries.log | 18 ++++++++++++++++++ .codex-subagents/agent09-key-entry-files.log | 18 ++++++++++++++++++ .codex-subagents/agent10-risk-boundaries.log | 18 ++++++++++++++++++ .codex-subagents/probe.md | 1 + .codex-subagents/probe2.md | 12 ++++++++++++ 12 files changed, 194 insertions(+) create mode 100644 .codex-subagents/agent01-structure.log create mode 100644 .codex-subagents/agent02-root-tooling.log create mode 100644 .codex-subagents/agent03-desktop.log create mode 100644 .codex-subagents/agent04-web.log create mode 100644 .codex-subagents/agent05-packages.log create mode 100644 .codex-subagents/agent06-util.log create mode 100644 .codex-subagents/agent07-docs.log create mode 100644 .codex-subagents/agent08-dependency-boundaries.log create mode 100644 .codex-subagents/agent09-key-entry-files.log create mode 100644 .codex-subagents/agent10-risk-boundaries.log create mode 100644 .codex-subagents/probe.md create mode 100644 .codex-subagents/probe2.md diff --git a/.codex-subagents/agent01-structure.log b/.codex-subagents/agent01-structure.log new file mode 100644 index 0000000000..410cf9b07a --- /dev/null +++ b/.codex-subagents/agent01-structure.log @@ -0,0 +1,19 @@ +2026-03-19T22:12:43.311209Z WARN codex_core::shell_snapshot: Failed to create shell snapshot for powershell: Shell snapshot not supported yet for PowerShell +OpenAI Codex v0.115.0-alpha.27 (research preview) +-------- +workdir: C:\Users\m4x\.codex\worktrees\f0ee\BatchExplorer +model: gpt-5.3-codex +provider: openai +approval: never +sandbox: danger-full-access +reasoning effort: xhigh +reasoning summaries: none +session id: 019d0828-789f-7fa3-9c99-0cc4e915b6ae +-------- +user +Explore repository structure and provide: (1) short architecture summary, (2) concise map of top-level directories and roles, (3) key entry points. Focus on desktop/, web/, packages/, util/, docs/. No file edits. +mcp: figma starting +mcp: linear starting +mcp: notion starting +mcp: playwright starting +mcp: figma ready diff --git a/.codex-subagents/agent02-root-tooling.log b/.codex-subagents/agent02-root-tooling.log new file mode 100644 index 0000000000..a1238f29e2 --- /dev/null +++ b/.codex-subagents/agent02-root-tooling.log @@ -0,0 +1,18 @@ +2026-03-19T22:12:43.436572Z WARN codex_core::shell_snapshot: Failed to create shell snapshot for powershell: Shell snapshot not supported yet for PowerShell +OpenAI Codex v0.115.0-alpha.27 (research preview) +-------- +workdir: C:\Users\m4x\.codex\worktrees\f0ee\BatchExplorer +model: gpt-5.3-codex +provider: openai +approval: never +sandbox: danger-full-access +reasoning effort: xhigh +reasoning summaries: none +session id: 019d0828-7926-7bf1-9ab4-46bf80529698 +-------- +user +Inspect root tooling and orchestration. Summarize package manager/workspace setup, lerna usage, root scripts, and build/test command flow. No file edits. +mcp: notion starting +mcp: playwright starting +mcp: figma starting +mcp: linear starting diff --git a/.codex-subagents/agent03-desktop.log b/.codex-subagents/agent03-desktop.log new file mode 100644 index 0000000000..69106466c2 --- /dev/null +++ b/.codex-subagents/agent03-desktop.log @@ -0,0 +1,18 @@ +2026-03-19T22:12:43.588820Z WARN codex_core::shell_snapshot: Failed to create shell snapshot for powershell: Shell snapshot not supported yet for PowerShell +OpenAI Codex v0.115.0-alpha.27 (research preview) +-------- +workdir: C:\Users\m4x\.codex\worktrees\f0ee\BatchExplorer +model: gpt-5.3-codex +provider: openai +approval: never +sandbox: danger-full-access +reasoning effort: xhigh +reasoning summaries: none +session id: 019d0828-79bf-76a1-aa4f-a5471ab543a5 +-------- +user +Inspect desktop/ architecture: Electron main process, Angular renderer bootstrap, major module boundaries, and startup path. List key entry files. No file edits. +mcp: figma starting +mcp: notion starting +mcp: playwright starting +mcp: linear starting diff --git a/.codex-subagents/agent04-web.log b/.codex-subagents/agent04-web.log new file mode 100644 index 0000000000..c7f6c0eb42 --- /dev/null +++ b/.codex-subagents/agent04-web.log @@ -0,0 +1,18 @@ +2026-03-19T22:12:43.758299Z WARN codex_core::shell_snapshot: Failed to create shell snapshot for powershell: Shell snapshot not supported yet for PowerShell +OpenAI Codex v0.115.0-alpha.27 (research preview) +-------- +workdir: C:\Users\m4x\.codex\worktrees\f0ee\BatchExplorer +model: gpt-5.3-codex +provider: openai +approval: never +sandbox: danger-full-access +reasoning effort: xhigh +reasoning summaries: none +session id: 019d0828-7a68-7a90-99a9-27571c441c21 +-------- +user +Inspect web/ architecture: bootstrap, routing, major feature modules, data access/mock boundaries, and dev entry path. List key entry files. No file edits. +mcp: playwright starting +mcp: figma starting +mcp: linear starting +mcp: notion starting diff --git a/.codex-subagents/agent05-packages.log b/.codex-subagents/agent05-packages.log new file mode 100644 index 0000000000..d55f4e0cde --- /dev/null +++ b/.codex-subagents/agent05-packages.log @@ -0,0 +1,18 @@ +2026-03-19T22:12:43.914539Z WARN codex_core::shell_snapshot: Failed to create shell snapshot for powershell: Shell snapshot not supported yet for PowerShell +OpenAI Codex v0.115.0-alpha.27 (research preview) +-------- +workdir: C:\Users\m4x\.codex\worktrees\f0ee\BatchExplorer +model: gpt-5.3-codex +provider: openai +approval: never +sandbox: danger-full-access +reasoning effort: xhigh +reasoning summaries: none +session id: 019d0828-7b04-75b2-b003-6012056a4ebe +-------- +user +Inspect packages/* and summarize each package purpose, primary exports, and dependencies on other internal packages. No file edits. +mcp: figma starting +mcp: notion starting +mcp: linear starting +mcp: playwright starting diff --git a/.codex-subagents/agent06-util.log b/.codex-subagents/agent06-util.log new file mode 100644 index 0000000000..8a3d81162f --- /dev/null +++ b/.codex-subagents/agent06-util.log @@ -0,0 +1,18 @@ +2026-03-19T22:12:44.083080Z WARN codex_core::shell_snapshot: Failed to create shell snapshot for powershell: Shell snapshot not supported yet for PowerShell +OpenAI Codex v0.115.0-alpha.27 (research preview) +-------- +workdir: C:\Users\m4x\.codex\worktrees\f0ee\BatchExplorer +model: gpt-5.3-codex +provider: openai +approval: never +sandbox: danger-full-access +reasoning effort: xhigh +reasoning summaries: none +session id: 019d0828-7bac-71a1-9d35-a9634ba8dc37 +-------- +user +Inspect util/* and summarize each utility package role, expected consumers, and notable scripts/tooling entry points. No file edits. +mcp: playwright starting +mcp: linear starting +mcp: figma starting +mcp: notion starting diff --git a/.codex-subagents/agent07-docs.log b/.codex-subagents/agent07-docs.log new file mode 100644 index 0000000000..844d285166 --- /dev/null +++ b/.codex-subagents/agent07-docs.log @@ -0,0 +1,18 @@ +2026-03-19T22:12:44.246057Z WARN codex_core::shell_snapshot: Failed to create shell snapshot for powershell: Shell snapshot not supported yet for PowerShell +OpenAI Codex v0.115.0-alpha.27 (research preview) +-------- +workdir: C:\Users\m4x\.codex\worktrees\f0ee\BatchExplorer +model: gpt-5.3-codex +provider: openai +approval: never +sandbox: danger-full-access +reasoning effort: xhigh +reasoning summaries: none +session id: 019d0828-7c4e-7a20-b977-72d284fc4587 +-------- +user +Inspect docs/ and summarize setup/testing/architecture docs and where contributors should start. No file edits. +mcp: figma starting +mcp: linear starting +mcp: playwright starting +mcp: notion starting diff --git a/.codex-subagents/agent08-dependency-boundaries.log b/.codex-subagents/agent08-dependency-boundaries.log new file mode 100644 index 0000000000..8e82aa3e3d --- /dev/null +++ b/.codex-subagents/agent08-dependency-boundaries.log @@ -0,0 +1,18 @@ +2026-03-19T22:12:44.428038Z WARN codex_core::shell_snapshot: Failed to create shell snapshot for powershell: Shell snapshot not supported yet for PowerShell +OpenAI Codex v0.115.0-alpha.27 (research preview) +-------- +workdir: C:\Users\m4x\.codex\worktrees\f0ee\BatchExplorer +model: gpt-5.3-codex +provider: openai +approval: never +sandbox: danger-full-access +reasoning effort: xhigh +reasoning summaries: none +session id: 019d0828-7cfd-7321-9a9a-8bdda3094547 +-------- +user +Map dependency boundaries across desktop, web, packages, util. Identify which layers depend on which and any one-way rules visible in code/config. No file edits. +mcp: linear starting +mcp: figma starting +mcp: playwright starting +mcp: notion starting diff --git a/.codex-subagents/agent09-key-entry-files.log b/.codex-subagents/agent09-key-entry-files.log new file mode 100644 index 0000000000..0cd4afe4cc --- /dev/null +++ b/.codex-subagents/agent09-key-entry-files.log @@ -0,0 +1,18 @@ +2026-03-19T22:12:44.616214Z WARN codex_core::shell_snapshot: Failed to create shell snapshot for powershell: Shell snapshot not supported yet for PowerShell +OpenAI Codex v0.115.0-alpha.27 (research preview) +-------- +workdir: C:\Users\m4x\.codex\worktrees\f0ee\BatchExplorer +model: gpt-5.3-codex +provider: openai +approval: never +sandbox: danger-full-access +reasoning effort: xhigh +reasoning summaries: none +session id: 019d0828-7dbb-76b3-83e5-15073c3ba73b +-------- +user +Produce a focused list of key entry-point files for desktop, web, and shared libs with one-line purpose each. No file edits. +mcp: figma starting +mcp: notion starting +mcp: linear starting +mcp: playwright starting diff --git a/.codex-subagents/agent10-risk-boundaries.log b/.codex-subagents/agent10-risk-boundaries.log new file mode 100644 index 0000000000..fded90762d --- /dev/null +++ b/.codex-subagents/agent10-risk-boundaries.log @@ -0,0 +1,18 @@ +2026-03-19T22:12:44.813224Z WARN codex_core::shell_snapshot: Failed to create shell snapshot for powershell: Shell snapshot not supported yet for PowerShell +OpenAI Codex v0.115.0-alpha.27 (research preview) +-------- +workdir: C:\Users\m4x\.codex\worktrees\f0ee\BatchExplorer +model: gpt-5.3-codex +provider: openai +approval: never +sandbox: danger-full-access +reasoning effort: xhigh +reasoning summaries: none +session id: 019d0828-7e81-7a32-b6b1-390c7b3bc412 +-------- +user +Identify potentially sensitive architectural boundaries (state ownership, Azure API integration seams, and cross-account orchestration layers) at a high level with file references. No file edits. +mcp: playwright starting +mcp: figma starting +mcp: linear starting +mcp: notion starting diff --git a/.codex-subagents/probe.md b/.codex-subagents/probe.md new file mode 100644 index 0000000000..f9d1584a39 --- /dev/null +++ b/.codex-subagents/probe.md @@ -0,0 +1 @@ +desktop docs packages util web \ No newline at end of file diff --git a/.codex-subagents/probe2.md b/.codex-subagents/probe2.md new file mode 100644 index 0000000000..32b224de74 --- /dev/null +++ b/.codex-subagents/probe2.md @@ -0,0 +1,12 @@ +.codex-subagents +.github +.vscode +.vsts +desktop +docs +electron-auto-app +eng +Localize +packages +util +web \ No newline at end of file From 49b2b1ad4055968e5ba35edcf2c6294d38ba988f Mon Sep 17 00:00:00 2001 From: max313iq Date: Fri, 20 Mar 2026 01:25:35 +0300 Subject: [PATCH 2/7] Add pool control workbench config and safe default throttling --- .../src/common/be-user-configuration.model.ts | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/desktop/src/common/be-user-configuration.model.ts b/desktop/src/common/be-user-configuration.model.ts index af74b052cb..d96dd192c3 100644 --- a/desktop/src/common/be-user-configuration.model.ts +++ b/desktop/src/common/be-user-configuration.model.ts @@ -8,6 +8,10 @@ export interface BEUserConfiguration extends BatchFlaskUserConfiguration { externalBrowserAuth: boolean; + features: { + poolControlWorkbench: boolean, + }; + subscriptions: { ignore: string[], }; @@ -36,7 +40,28 @@ export interface BEUserConfiguration extends BatchFlaskUserConfiguration { tenants: { [tenantId: string]: "active" | "inactive" - } + }; + + poolControlWorkbench: { + discovery: { + includeNodeCountsInMasterTable: boolean, + maxPoolsPerAccountPerPage: number, + }, + refresh: { + autoRefreshEnabled: boolean, + autoRefreshIntervalSeconds: number, + }, + throttling: { + concurrency: number, + delayMsBetweenRequests: number, + retryAttempts: number, + retryBackoffSeconds: number[], + }, + safety: { + requireConfirmationsForDestructiveActions: boolean, + maxNodeRemoveBatchSize: number, + }, + }; } export const DEFAULT_BE_USER_CONFIGURATION: BEUserConfiguration = { @@ -46,6 +71,9 @@ export const DEFAULT_BE_USER_CONFIGURATION: BEUserConfiguration = { subscriptions: { ignore: [], }, + features: { + poolControlWorkbench: false, + }, tenants: {}, update: { channel: null, @@ -61,6 +89,26 @@ export const DEFAULT_BE_USER_CONFIGURATION: BEUserConfiguration = { repo: "Azure/BatchExplorer-data", branch: "master", }, + poolControlWorkbench: { + discovery: { + includeNodeCountsInMasterTable: true, + maxPoolsPerAccountPerPage: 1000, + }, + refresh: { + autoRefreshEnabled: false, + autoRefreshIntervalSeconds: 30, + }, + throttling: { + concurrency: 1, + delayMsBetweenRequests: 250, + retryAttempts: 5, + retryBackoffSeconds: [2, 4, 8, 16, 32], + }, + safety: { + requireConfirmationsForDestructiveActions: true, + maxNodeRemoveBatchSize: 100, + }, + }, theme: "classic", externalBrowserAuth: true }; From 42baa2957d957756b8e693b5b3638f3496701ac8 Mon Sep 17 00:00:00 2001 From: max313iq Date: Fri, 20 Mar 2026 01:45:17 +0300 Subject: [PATCH 3/7] Add pool control workbench with safe cross-account Batch operations --- .../components/pool/pool-routing.module.ts | 5 + .../src/app/components/pool/pool.module.ts | 2 + desktop/src/app/components/workbench/index.ts | 1 + .../pool-control-workbench.component.ts | 303 +++++++++++++ .../workbench/pool-control-workbench.html | 132 ++++++ .../workbench/pool-control-workbench.scss | 98 ++++ .../workbench/pool-detail-panel.component.ts | 246 ++++++++++ .../workbench/pool-detail-panel.html | 152 +++++++ .../workbench/pool-detail-panel.scss | 152 +++++++ .../workbench/start-task-editor.component.ts | 154 +++++++ .../workbench/start-task-editor.html | 55 +++ .../workbench/start-task-editor.scss | 44 ++ .../azure-batch/core/batch-http.service.ts | 77 ++-- desktop/src/app/services/index.ts | 1 + .../workbench/batch-node-actions.service.ts | 419 ++++++++++++++++++ .../workbench/batch-node-actions.spec.ts | 28 ++ .../workbench/batch-pool-actions.service.ts | 396 +++++++++++++++++ desktop/src/app/services/workbench/index.ts | 6 + .../workbench/request-scheduler.spec.ts | 75 ++++ .../services/workbench/request-scheduler.ts | 219 +++++++++ .../workbench/start-task-apply.service.ts | 303 +++++++++++++ .../workbench/start-task-apply.spec.ts | 111 +++++ .../workbench/workbench-discovery.service.ts | 364 +++++++++++++++ .../app/services/workbench/workbench-types.ts | 40 ++ 24 files changed, 3352 insertions(+), 31 deletions(-) create mode 100644 desktop/src/app/components/workbench/index.ts create mode 100644 desktop/src/app/components/workbench/pool-control-workbench.component.ts create mode 100644 desktop/src/app/components/workbench/pool-control-workbench.html create mode 100644 desktop/src/app/components/workbench/pool-control-workbench.scss create mode 100644 desktop/src/app/components/workbench/pool-detail-panel.component.ts create mode 100644 desktop/src/app/components/workbench/pool-detail-panel.html create mode 100644 desktop/src/app/components/workbench/pool-detail-panel.scss create mode 100644 desktop/src/app/components/workbench/start-task-editor.component.ts create mode 100644 desktop/src/app/components/workbench/start-task-editor.html create mode 100644 desktop/src/app/components/workbench/start-task-editor.scss create mode 100644 desktop/src/app/services/workbench/batch-node-actions.service.ts create mode 100644 desktop/src/app/services/workbench/batch-node-actions.spec.ts create mode 100644 desktop/src/app/services/workbench/batch-pool-actions.service.ts create mode 100644 desktop/src/app/services/workbench/index.ts create mode 100644 desktop/src/app/services/workbench/request-scheduler.spec.ts create mode 100644 desktop/src/app/services/workbench/request-scheduler.ts create mode 100644 desktop/src/app/services/workbench/start-task-apply.service.ts create mode 100644 desktop/src/app/services/workbench/start-task-apply.spec.ts create mode 100644 desktop/src/app/services/workbench/workbench-discovery.service.ts create mode 100644 desktop/src/app/services/workbench/workbench-types.ts diff --git a/desktop/src/app/components/pool/pool-routing.module.ts b/desktop/src/app/components/pool/pool-routing.module.ts index b2792f4cd6..8f5a0a0bb7 100644 --- a/desktop/src/app/components/pool/pool-routing.module.ts +++ b/desktop/src/app/components/pool/pool-routing.module.ts @@ -1,9 +1,14 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; +import { PoolControlWorkbenchComponent } from "app/components/workbench"; import { PoolDefaultComponent, PoolDetailsComponent } from "./details"; import { PoolHomeComponent } from "./home/pool-home.component"; const routes: Routes = [ + { + path: "workbench", + component: PoolControlWorkbenchComponent, + }, { path: "", component: PoolHomeComponent, diff --git a/desktop/src/app/components/pool/pool.module.ts b/desktop/src/app/components/pool/pool.module.ts index b3f1205178..c074846f44 100644 --- a/desktop/src/app/components/pool/pool.module.ts +++ b/desktop/src/app/components/pool/pool.module.ts @@ -19,6 +19,7 @@ import { PoolGraphsModule } from "app/components/pool/graphs"; import { PoolHomeComponent } from "app/components/pool/home/pool-home.component"; import { StartTaskModule } from "app/components/pool/start-task"; import { TaskBaseModule } from "app/components/task/base"; +import { PoolControlWorkbenchComponent } from "app/components/workbench"; import { EditPoolModule } from "./action/edit/edit-pool.module"; import { NetworkConfigurationModule } from "./network-configuration"; import { PoolRoutingModule } from "./pool-routing.module"; @@ -30,6 +31,7 @@ const components = [ PoolAdvancedFilterComponent, PoolCreateBasicDialogComponent, PoolHomeComponent, + PoolControlWorkbenchComponent, PoolListComponent, PoolScalePickerComponent, VmSizePickerComponent, diff --git a/desktop/src/app/components/workbench/index.ts b/desktop/src/app/components/workbench/index.ts new file mode 100644 index 0000000000..ad3369fc7d --- /dev/null +++ b/desktop/src/app/components/workbench/index.ts @@ -0,0 +1 @@ +export * from "./pool-control-workbench.component"; diff --git a/desktop/src/app/components/workbench/pool-control-workbench.component.ts b/desktop/src/app/components/workbench/pool-control-workbench.component.ts new file mode 100644 index 0000000000..4dafc1d96f --- /dev/null +++ b/desktop/src/app/components/workbench/pool-control-workbench.component.ts @@ -0,0 +1,303 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, OnDestroy, OnInit, Optional } from "@angular/core"; +import { UserConfigurationService } from "@batch-flask/core"; +import { ListSelection } from "@batch-flask/core/list"; +import { TableConfig } from "@batch-flask/ui"; +import { BEUserConfiguration } from "common"; +import { Observable, Subject, Subscription, firstValueFrom, timer } from "rxjs"; +import { takeUntil } from "rxjs/operators"; + +interface WorkbenchPoolRow { + uid: string; + subscriptionId: string; + accountName: string; + location: string; + poolId: string; + allocationState: string; + nodeCountsByState: { + idle: number; + running: number; + starting: number; + startTaskFailed: number; + unusable: number; + }; + alerts: string[]; +} + +interface DiscoveryServiceLike { + listAccounts: () => Observable | Promise | any; + listPools: (account: any) => Observable | Promise | any; +} + +@Component({ + selector: "bl-pool-control-workbench", + templateUrl: "pool-control-workbench.html", + styleUrls: ["pool-control-workbench.scss"], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PoolControlWorkbenchComponent implements OnInit, OnDestroy { + public static breadcrumb() { + return { name: "Pool Control Workbench" }; + } + + public tableConfig: TableConfig = { + id: "pool-control-workbench-table", + showCheckbox: true, + sorting: { + subscriptionId: true, + accountName: true, + location: true, + poolId: true, + allocationState: true, + }, + }; + + public isFeatureEnabled = false; + public isRefreshing = false; + public refreshError: string | null = null; + public statusMessage = "No data loaded."; + + public activeItem: WorkbenchPoolRow | null = null; + public selection = new ListSelection(); + + public allRows: WorkbenchPoolRow[] = []; + public displayedRows: WorkbenchPoolRow[] = []; + + public subscriptionFilter = ""; + public accountFilter = ""; + public regionFilter = ""; + public searchFilter = ""; + public onlyAlerts = false; + + public subscriptionOptions: string[] = []; + public accountOptions: string[] = []; + public regionOptions: string[] = []; + + public autoRefreshEnabled = false; + public autoRefreshIntervalSeconds = 30; + + private _autoRefreshSub: Subscription | null = null; + private _destroy = new Subject(); + + constructor( + private settingsService: UserConfigurationService, + private changeDetector: ChangeDetectorRef, + @Optional() @Inject("WorkbenchDiscoveryService") private discoveryService: DiscoveryServiceLike | null, + ) { + } + + public ngOnInit() { + this.settingsService.watch("features").pipe(takeUntil(this._destroy)).subscribe((features: any) => { + this.isFeatureEnabled = Boolean(features && features.poolControlWorkbench); + this._configureAutoRefresh(); + if (this.isFeatureEnabled && this.displayedRows.length === 0) { + this.refresh(); + } + this.changeDetector.markForCheck(); + }); + + this.settingsService.watch("poolControlWorkbench").pipe(takeUntil(this._destroy)).subscribe((settings: any) => { + const refreshSettings = settings && settings.refresh ? settings.refresh : {}; + this.autoRefreshEnabled = Boolean(refreshSettings.autoRefreshEnabled); + this.autoRefreshIntervalSeconds = this._safeIntervalSeconds(refreshSettings.autoRefreshIntervalSeconds); + this._configureAutoRefresh(); + this.changeDetector.markForCheck(); + }); + } + + public ngOnDestroy() { + if (this._autoRefreshSub) { + this._autoRefreshSub.unsubscribe(); + this._autoRefreshSub = null; + } + this._destroy.next(); + this._destroy.complete(); + } + + public get selectedCount(): number { + return this.selection.all ? this.displayedRows.length : this.selection.keys.size; + } + + public onAutoRefreshChanged() { + this._configureAutoRefresh(); + } + + public clearSelection() { + this.selection.clear(); + } + + public applyFilters() { + const search = this.searchFilter.trim().toLowerCase(); + this.displayedRows = this.allRows.filter((row) => { + if (this.subscriptionFilter && row.subscriptionId !== this.subscriptionFilter) { + return false; + } + if (this.accountFilter && row.accountName !== this.accountFilter) { + return false; + } + if (this.regionFilter && row.location !== this.regionFilter) { + return false; + } + if (this.onlyAlerts && row.alerts.length === 0) { + return false; + } + if (search) { + const haystack = `${row.poolId} ${row.accountName} ${row.location} ${row.subscriptionId}`.toLowerCase(); + if (!haystack.includes(search)) { + return false; + } + } + return true; + }); + this.statusMessage = this.displayedRows.length === 0 ? "No pools match current filters." : ""; + } + + public async refresh() { + if (this.isRefreshing || !this.isFeatureEnabled) { + return; + } + + this.isRefreshing = true; + this.refreshError = null; + this.statusMessage = "Refreshing pool inventory..."; + this.changeDetector.markForCheck(); + + try { + if (!this.discoveryService + || typeof this.discoveryService.listAccounts !== "function" + || typeof this.discoveryService.listPools !== "function") { + this.allRows = []; + this.displayedRows = []; + this.statusMessage = "Discovery service is unavailable. Waiting for data layer integration."; + return; + } + + const accounts = await this._toPromise(this.discoveryService.listAccounts()); + const rows: WorkbenchPoolRow[] = []; + const accountList = Array.isArray(accounts) ? accounts : []; + + for (const account of accountList) { + const pools = await this._toPromise(this.discoveryService.listPools(account)); + const poolList = Array.isArray(pools) ? pools : []; + for (const pool of poolList) { + rows.push(this._mapPoolRow(account, pool)); + } + } + + this.allRows = rows; + this._rebuildFilterOptions(); + this.applyFilters(); + if (rows.length > 0) { + this.statusMessage = ""; + } else if (!this.statusMessage) { + this.statusMessage = "No pools were discovered."; + } + } catch (error) { + this.refreshError = this._describeError(error); + this.statusMessage = "Refresh failed."; + } finally { + this.isRefreshing = false; + this.changeDetector.markForCheck(); + } + } + + private _configureAutoRefresh() { + if (this._autoRefreshSub) { + this._autoRefreshSub.unsubscribe(); + this._autoRefreshSub = null; + } + + if (!this.isFeatureEnabled || !this.autoRefreshEnabled) { + return; + } + + this._autoRefreshSub = timer(this.autoRefreshIntervalSeconds * 1000, this.autoRefreshIntervalSeconds * 1000) + .pipe(takeUntil(this._destroy)) + .subscribe(() => { + this.refresh(); + }); + } + + private _safeIntervalSeconds(value: any): number { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 30; + } + + private _rebuildFilterOptions() { + this.subscriptionOptions = this._sortedUnique(this.allRows.map(x => x.subscriptionId)); + this.accountOptions = this._sortedUnique(this.allRows.map(x => x.accountName)); + this.regionOptions = this._sortedUnique(this.allRows.map(x => x.location)); + } + + private _sortedUnique(values: string[]): string[] { + return [...new Set(values.filter(x => Boolean(x)))].sort((a, b) => a.localeCompare(b)); + } + + private _mapPoolRow(account: any, pool: any): WorkbenchPoolRow { + const subscriptionId = this._pick(account, ["subscriptionId", "subscription.id", "subscription.subscriptionId"], "unknown-subscription"); + const accountName = this._pick(account, ["accountName", "displayName", "name"], "unknown-account"); + const location = this._pick(account, ["location", "region"], "unknown-region"); + const poolId = this._pick(pool, ["poolId", "id"], "unknown-pool"); + const allocationState = this._pick(pool, ["allocationState"], "unknown"); + const nodeCounts = pool && pool.nodeCountsByState ? pool.nodeCountsByState : {}; + const alerts = Array.isArray(pool && pool.alerts) ? pool.alerts : []; + + return { + uid: `${subscriptionId}|${accountName}|${poolId}`, + subscriptionId, + accountName, + location, + poolId, + allocationState, + nodeCountsByState: { + idle: this._asNumber(nodeCounts.idle), + running: this._asNumber(nodeCounts.running), + starting: this._asNumber(nodeCounts.starting), + startTaskFailed: this._asNumber(nodeCounts.startTaskFailed), + unusable: this._asNumber(nodeCounts.unusable), + }, + alerts, + }; + } + + private _asNumber(value: any): number { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0; + } + + private _pick(source: any, paths: string[], fallback: string): string { + for (const path of paths) { + let current = source; + const segments = path.split("."); + for (const segment of segments) { + current = current && current[segment]; + } + if (typeof current === "string" && current.trim().length > 0) { + return current; + } + } + return fallback; + } + + private _describeError(error: any): string { + if (!error) { + return "Unknown refresh error"; + } + if (typeof error === "string") { + return error; + } + if (error.message) { + return error.message; + } + return "Unknown refresh error"; + } + + private _toPromise(value: T | Promise | Observable): Promise { + if (value && typeof (value as any).subscribe === "function") { + return firstValueFrom(value as Observable); + } + if (value && typeof (value as any).then === "function") { + return value as Promise; + } + return Promise.resolve(value as T); + } +} diff --git a/desktop/src/app/components/workbench/pool-control-workbench.html b/desktop/src/app/components/workbench/pool-control-workbench.html new file mode 100644 index 0000000000..aa52c31ad2 --- /dev/null +++ b/desktop/src/app/components/workbench/pool-control-workbench.html @@ -0,0 +1,132 @@ +
+
+
+

Pool Control Workbench

+

Cross-account pool visibility with safe refresh controls.

+
+
+ + + Refresh + + + Every {{autoRefreshIntervalSeconds}}s +
+
+ +
+ Pool Control Workbench is disabled. Enable features.poolControlWorkbench in user settings. +
+ +
+
+ + + + + + + + + +
+ +
+ {{selectedCount}} selected + Clear selection + Bulk actions are placeholders in this slice. +
+ + + + +
Subscription
+
{{row.subscriptionId}}
+
+ + +
Account
+
{{row.accountName}}
+
+ + +
Region
+
{{row.location}}
+
+ + +
Pool
+
{{row.poolId}}
+
+ + +
Allocation
+
{{row.allocationState}}
+
+ + +
Node counts
+
+ Idle: {{row.nodeCountsByState.idle}} + Running: {{row.nodeCountsByState.running}} + Starting: {{row.nodeCountsByState.starting}} + StartTaskFailed: {{row.nodeCountsByState.startTaskFailed}} + Unusable: {{row.nodeCountsByState.unusable}} +
+
+ + +
Alerts
+
{{row.alerts.length > 0 ? row.alerts.join(', ') : 'None'}}
+
+ + +
Actions
+
+ + Actions + +
+
+
+ +
{{statusMessage}}
+
{{statusMessage}}
+
{{refreshError}}
+
+
diff --git a/desktop/src/app/components/workbench/pool-control-workbench.scss b/desktop/src/app/components/workbench/pool-control-workbench.scss new file mode 100644 index 0000000000..5ed14706ed --- /dev/null +++ b/desktop/src/app/components/workbench/pool-control-workbench.scss @@ -0,0 +1,98 @@ +.pool-control-workbench { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + + .header { + align-items: flex-start; + display: flex; + justify-content: space-between; + gap: 12px; + } + + h2 { + margin: 0; + } + + .subtitle { + color: #666; + margin: 4px 0 0; + } + + .header-actions { + align-items: center; + display: flex; + gap: 10px; + } + + .auto-refresh-toggle { + align-items: center; + display: inline-flex; + gap: 6px; + margin: 0; + } + + .refresh-interval { + color: #666; + font-size: 12px; + } + + .feature-off { + background: #f8f8f8; + border: 1px solid #d8d8d8; + border-radius: 4px; + padding: 12px; + } + + .filters { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + padding: 8px 0; + } + + .filters label { + display: flex; + flex-direction: column; + font-size: 12px; + gap: 4px; + } + + .filters input, + .filters select { + border: 1px solid #ccc; + border-radius: 4px; + font-size: 13px; + padding: 6px 8px; + } + + .filters .alerts-only { + align-items: center; + display: inline-flex; + flex-direction: row; + gap: 8px; + margin-top: 20px; + } + + .selection-bar { + align-items: center; + display: flex; + gap: 10px; + } + + .placeholder-actions { + color: #777; + font-size: 12px; + } + + .status-message { + color: #666; + font-size: 12px; + } + + .error-message { + color: #b00020; + font-size: 12px; + } +} diff --git a/desktop/src/app/components/workbench/pool-detail-panel.component.ts b/desktop/src/app/components/workbench/pool-detail-panel.component.ts new file mode 100644 index 0000000000..660cb877e0 --- /dev/null +++ b/desktop/src/app/components/workbench/pool-detail-panel.component.ts @@ -0,0 +1,246 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, Output } from "@angular/core"; +import { Node } from "app/models"; +import { + BatchNodeActionsService, + BulkNodeActionResult, + DisableSchedulingOption, + NodeActionResult, +} from "app/services/workbench/batch-node-actions.service"; + +export interface PoolDetailSummary { + accountName?: string; + location?: string; + allocationState?: string; + nodeCountsByState?: { [state: string]: number }; + alerts?: string[]; +} + +@Component({ + selector: "bl-pool-detail-panel", + templateUrl: "pool-detail-panel.html", + styleUrls: ["pool-detail-panel.scss"], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PoolDetailPanelComponent { + @Input() public account: unknown; + @Input() public poolId: string; + @Input() public summary: PoolDetailSummary | null = null; + + @Output() public bulkActionCompleted = new EventEmitter(); + @Output() public nodeActionCompleted = new EventEmitter(); + + public activeTabIndex = 0; + public loadingNodes = false; + public nodesLoaded = false; + public actionInProgress = false; + public nodes: Node[] = []; + public actionMessage: string | null = null; + public actionError: string | null = null; + public lastBulkResult: BulkNodeActionResult | null = null; + public lastNodeAction: NodeActionResult | null = null; + + private _selectedNodeIds = new Set(); + private _selectionPoolId: string | null = null; + + constructor( + private nodeActions: BatchNodeActionsService, + private changeDetector: ChangeDetectorRef, + ) { } + + public get selectedNodeCount(): number { + return this._selectedNodeIds.size; + } + + public get allNodesSelected(): boolean { + return this.nodes.length > 0 && this._selectedNodeIds.size === this.nodes.length; + } + + public get partialNodesSelected(): boolean { + return this._selectedNodeIds.size > 0 && this._selectedNodeIds.size < this.nodes.length; + } + + public get hasNodeCounts(): boolean { + const counts = this.summary?.nodeCountsByState; + return Boolean(counts && Object.keys(counts).length > 0); + } + + public onTabIndexChange(index: number) { + this.activeTabIndex = index; + if (index === 1) { + this.loadNodes(false); + } + } + + public async refreshNodes() { + await this.loadNodes(true); + } + + public toggleNodeSelection(nodeId: string, selected: boolean) { + if (selected) { + this._selectedNodeIds.add(nodeId); + } else { + this._selectedNodeIds.delete(nodeId); + } + this.changeDetector.markForCheck(); + } + + public toggleAllNodes(selected: boolean) { + if (selected) { + for (const node of this.nodes) { + this._selectedNodeIds.add(node.id); + } + } else { + this._selectedNodeIds.clear(); + } + this.changeDetector.markForCheck(); + } + + public isNodeSelected(nodeId: string): boolean { + return this._selectedNodeIds.has(nodeId); + } + + public async bulkRemoveSelected() { + await this._executeBulk("Removing selected nodes", () => { + return this.nodeActions.removeNodes(this.account, this.poolId, this._selectedIds()).toPromise(); + }); + } + + public async bulkRebootSelected() { + await this._executeBulk("Rebooting selected nodes", () => { + return this.nodeActions.rebootNodes(this.account, this.poolId, this._selectedIds()).toPromise(); + }); + } + + public async bulkReimageSelected() { + await this._executeBulk("Reimaging selected nodes", () => { + return this.nodeActions.reimageNodes(this.account, this.poolId, this._selectedIds()).toPromise(); + }); + } + + public async bulkEnableSchedulingSelected() { + await this._executeBulk("Enabling scheduling on selected nodes", () => { + return this.nodeActions.enableSchedulingNodes(this.account, this.poolId, this._selectedIds()).toPromise(); + }); + } + + public async bulkDisableSchedulingSelected(option: DisableSchedulingOption = "taskCompletion") { + await this._executeBulk("Disabling scheduling on selected nodes", () => { + return this.nodeActions.disableSchedulingNodes(this.account, this.poolId, this._selectedIds(), option).toPromise(); + }); + } + + public async rebootNode(node: Node) { + await this._executeSingleAction("Rebooting node", () => { + return this.nodeActions.rebootNode(this.account, this.poolId, node.id).toPromise(); + }); + } + + public async reimageNode(node: Node) { + await this._executeSingleAction("Reimaging node", () => { + return this.nodeActions.reimageNode(this.account, this.poolId, node.id).toPromise(); + }); + } + + public async enableScheduling(node: Node) { + await this._executeSingleAction("Enabling scheduling", () => { + return this.nodeActions.enableScheduling(this.account, this.poolId, node.id).toPromise(); + }); + } + + public async disableScheduling(node: Node, option: DisableSchedulingOption = "taskCompletion") { + await this._executeSingleAction("Disabling scheduling", () => { + return this.nodeActions.disableScheduling(this.account, this.poolId, node.id, option).toPromise(); + }); + } + + public nodeErrorsCount(node: Node): number { + return node.errors ? node.errors.size : 0; + } + + public async loadNodes(force: boolean) { + if (!this.account || !this.poolId || this.actionInProgress || this.loadingNodes) { + return; + } + if (this._selectionPoolId !== this.poolId) { + this._selectionPoolId = this.poolId; + this._selectedNodeIds.clear(); + this.nodes = []; + this.nodesLoaded = false; + } + if (this.nodesLoaded && !force) { + return; + } + + this.loadingNodes = true; + this.actionError = null; + this.changeDetector.markForCheck(); + + try { + this.nodes = await this.nodeActions.listNodes(this.account, this.poolId).toPromise(); + this.nodesLoaded = true; + this._selectedNodeIds.clear(); + } catch (error) { + this.actionError = error?.message || "Failed to load pool nodes."; + } finally { + this.loadingNodes = false; + this.changeDetector.markForCheck(); + } + } + + private _selectedIds(): string[] { + return Array.from(this._selectedNodeIds.values()); + } + + private async _executeBulk( + actionMessage: string, + callback: () => Promise, + ) { + if (!this.account || !this.poolId || this._selectedNodeIds.size === 0 || this.actionInProgress) { + return; + } + + this.actionInProgress = true; + this.actionMessage = actionMessage; + this.actionError = null; + this.changeDetector.markForCheck(); + + try { + this.lastBulkResult = await callback(); + this.bulkActionCompleted.emit(this.lastBulkResult); + await this.loadNodes(true); + this._selectedNodeIds.clear(); + } catch (error) { + this.actionError = error?.message || "Bulk action failed."; + } finally { + this.actionInProgress = false; + this.actionMessage = null; + this.changeDetector.markForCheck(); + } + } + + private async _executeSingleAction( + actionMessage: string, + callback: () => Promise, + ) { + if (!this.account || !this.poolId || this.actionInProgress) { + return; + } + + this.actionInProgress = true; + this.actionMessage = actionMessage; + this.actionError = null; + this.changeDetector.markForCheck(); + + try { + this.lastNodeAction = await callback(); + this.nodeActionCompleted.emit(this.lastNodeAction); + await this.loadNodes(true); + } catch (error) { + this.actionError = error?.message || "Node action failed."; + } finally { + this.actionInProgress = false; + this.actionMessage = null; + this.changeDetector.markForCheck(); + } + } +} diff --git a/desktop/src/app/components/workbench/pool-detail-panel.html b/desktop/src/app/components/workbench/pool-detail-panel.html new file mode 100644 index 0000000000..747c825bf4 --- /dev/null +++ b/desktop/src/app/components/workbench/pool-detail-panel.html @@ -0,0 +1,152 @@ +
+
+
+
Pool
+
{{poolId}}
+
+
+
Account
+
{{summary?.accountName || "-"}}
+
+
+
Region
+
{{summary?.location || "-"}}
+
+
+
Allocation state
+
{{summary?.allocationState || "-"}}
+
+
+ +
+
{{alert}}
+
+ + + +
+
+ Node state counts will appear here after discovery loads. +
+ +
+
+
{{item.key}}
+
{{item.value}}
+
+
+
+
+ + +
+ + + + + + +
+ +
+ + {{actionMessage}} +
+ +
+ {{actionError}} +
+ +
+ {{lastBulkResult.action}} finished: + {{lastBulkResult.successCount}} succeeded, + {{lastBulkResult.failureCount}} failed. +
+ +
+ Last node action: + + {{lastNodeAction.success ? "success" : "failed"}} + +
+ +
+ + Loading nodes... +
+ +
+ No nodes found in this pool. +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + + Node IDStateSchedulingState transitionErrorsActions
+ + + {{node.id}}{{node.state}}{{node.schedulingState}}{{node.stateTransitionTime | date: 'short'}}{{nodeErrorsCount(node)}} + + + + +
+
+
+
+ + +
+ Select a pool to view node details. +
+
diff --git a/desktop/src/app/components/workbench/pool-detail-panel.scss b/desktop/src/app/components/workbench/pool-detail-panel.scss new file mode 100644 index 0000000000..20d2d3ffd0 --- /dev/null +++ b/desktop/src/app/components/workbench/pool-detail-panel.scss @@ -0,0 +1,152 @@ +.pool-detail-panel { + display: flex; + flex-direction: column; + gap: 12px; +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.summary-item { + border: 1px solid #d8dde6; + border-radius: 4px; + padding: 8px 10px; + background: #fff; +} + +.summary-label { + font-size: 12px; + color: #66788a; +} + +.summary-value { + margin-top: 4px; + font-size: 14px; + color: #1f2d3d; +} + +.alerts { + display: flex; + flex-direction: column; + gap: 6px; +} + +.alert-item { + border-left: 3px solid #f8a023; + background: #fff6e5; + padding: 6px 8px; + color: #7a4a00; +} + +.overview-panel { + padding: 12px 0; +} + +.overview-empty { + color: #66788a; +} + +.count-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.count-card { + border: 1px solid #d8dde6; + border-radius: 4px; + padding: 8px; + background: #fff; +} + +.count-key { + text-transform: capitalize; + color: #66788a; + font-size: 12px; +} + +.count-value { + margin-top: 4px; + font-size: 18px; + color: #12223a; +} + +.nodes-toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 12px 0; +} + +.action-message { + color: #3b6ea8; + margin-bottom: 8px; +} + +.action-error { + color: #b42318; + margin-bottom: 8px; +} + +.bulk-summary, +.node-summary { + margin-bottom: 8px; + color: #34495e; +} + +.success { + color: #137333; +} + +.failure { + color: #b42318; +} + +.nodes-loading, +.nodes-empty, +.no-selection { + color: #66788a; + padding: 12px 0; +} + +.nodes-table { + width: 100%; + border-collapse: collapse; +} + +.nodes-table th, +.nodes-table td { + border-bottom: 1px solid #edf1f5; + text-align: left; + padding: 8px; + vertical-align: middle; +} + +.checkbox-column { + width: 40px; +} + +.actions-column { + min-width: 240px; +} + +@media (max-width: 1200px) { + .summary-grid, + .count-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 700px) { + .summary-grid, + .count-grid { + grid-template-columns: 1fr; + } + + .actions-column { + min-width: 180px; + } +} diff --git a/desktop/src/app/components/workbench/start-task-editor.component.ts b/desktop/src/app/components/workbench/start-task-editor.component.ts new file mode 100644 index 0000000000..102ee54e70 --- /dev/null +++ b/desktop/src/app/components/workbench/start-task-editor.component.ts @@ -0,0 +1,154 @@ +import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { AbstractControl, FormBuilder, FormGroup, ValidationErrors, Validators } from "@angular/forms"; +import { StartTaskDto } from "app/models/dtos"; +import { + StartTaskApplyRequest, + StartTaskApplyScope, + StartTaskApplyTarget, +} from "app/services/workbench/start-task-apply.service"; + +interface StartTaskEditorForm { + scope: StartTaskApplyScope; + commandLine: string; + waitForSuccess: boolean; + maxTaskRetryCount: number; + resourceFilesJson: string; +} + +@Component({ + selector: "bl-start-task-editor", + templateUrl: "start-task-editor.html", + styleUrls: ["start-task-editor.scss"], +}) +export class StartTaskEditorComponent { + @Input() public currentTarget: StartTaskApplyTarget | null = null; + @Input() public selectedTargets: StartTaskApplyTarget[] = []; + @Input() public allTargets: StartTaskApplyTarget[] = []; + @Input() public requireConfirmation = true; + + @Output() public previewRequested = new EventEmitter(); + @Output() public applyRequested = new EventEmitter(); + + public confirmationPending = false; + public parsingError = ""; + + public form: FormGroup; + + constructor(formBuilder: FormBuilder) { + this.form = formBuilder.group({ + scope: ["current", Validators.required], + commandLine: ["", [Validators.required, this._trimmedRequired]], + waitForSuccess: [true], + maxTaskRetryCount: [0, [Validators.min(-1), Validators.max(10)]], + resourceFilesJson: [""], + }); + } + + public get commandLineInvalid(): boolean { + const control = this.form.controls.commandLine; + return control.invalid && (control.touched || control.dirty); + } + + public get affectedPoolCount(): number { + return this._resolveTargets(this.form.value.scope).length; + } + + public preview() { + if (!this._canSubmit()) { + return; + } + const request = this._buildRequest(true, true); + if (request) { + this.previewRequested.emit(request); + } + } + + public beginApply() { + if (!this._canSubmit()) { + return; + } + this.confirmationPending = this.requireConfirmation; + if (!this.requireConfirmation) { + this.confirmApply(); + } + } + + public cancelApply() { + this.confirmationPending = false; + } + + public confirmApply() { + if (!this._canSubmit()) { + return; + } + const request = this._buildRequest(false, true); + if (request) { + this.applyRequested.emit(request); + this.confirmationPending = false; + } + } + + private _canSubmit() { + this.form.markAllAsTouched(); + this.parsingError = ""; + return this.form.valid; + } + + private _buildRequest(dryRun: boolean, confirmationAccepted: boolean): StartTaskApplyRequest | null { + const value = this.form.value as StartTaskEditorForm; + const startTask = this._buildStartTaskModel(value); + if (!startTask) { + return null; + } + return { + scope: value.scope, + startTask, + currentTarget: this.currentTarget || undefined, + selectedTargets: this.selectedTargets, + allTargets: this.allTargets, + dryRun, + confirmationAccepted, + }; + } + + private _buildStartTaskModel(value: StartTaskEditorForm): Partial | null { + const commandLine = value.commandLine && value.commandLine.trim(); + const startTask: Partial = { + commandLine, + waitForSuccess: value.waitForSuccess, + maxTaskRetryCount: value.maxTaskRetryCount, + }; + + const resourceFilesJson = (value.resourceFilesJson || "").trim(); + if (resourceFilesJson.length > 0) { + try { + const resourceFiles = JSON.parse(resourceFilesJson); + if (!Array.isArray(resourceFiles)) { + this.parsingError = "resourceFiles JSON must be an array."; + return null; + } + startTask.resourceFiles = resourceFiles; + } catch (error) { + this.parsingError = "resourceFiles JSON is invalid."; + return null; + } + } + + return startTask; + } + + private _resolveTargets(scope: StartTaskApplyScope): StartTaskApplyTarget[] { + if (scope === "current") { + return this.currentTarget ? [this.currentTarget] : []; + } + if (scope === "selected") { + return this.selectedTargets || []; + } + return this.allTargets || []; + } + + private _trimmedRequired(control: AbstractControl): ValidationErrors | null { + const value = String(control.value || ""); + return value.trim().length > 0 ? null : { required: true }; + } +} diff --git a/desktop/src/app/components/workbench/start-task-editor.html b/desktop/src/app/components/workbench/start-task-editor.html new file mode 100644 index 0000000000..acef3d7d0d --- /dev/null +++ b/desktop/src/app/components/workbench/start-task-editor.html @@ -0,0 +1,55 @@ +
+

Start task editor

+ +
+ + + + + +
Start task command line is required.
+ + + + + + + + + +
{{ parsingError }}
+
+ +
+ Affected pools: {{ affectedPoolCount }} +
+ +
+ + +
+ +
+

+ You are applying this start task to {{ affectedPoolCount }} pool(s). + This may trigger node restarts depending on operational policy. +

+
+ + +
+
+
diff --git a/desktop/src/app/components/workbench/start-task-editor.scss b/desktop/src/app/components/workbench/start-task-editor.scss new file mode 100644 index 0000000000..582a9ded07 --- /dev/null +++ b/desktop/src/app/components/workbench/start-task-editor.scss @@ -0,0 +1,44 @@ +.start-task-editor { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px; +} + +.editor-grid { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + column-gap: 12px; + row-gap: 8px; + align-items: start; +} + +.editor-grid textarea, +.editor-grid input[type="number"], +.editor-grid select { + width: 100%; + box-sizing: border-box; +} + +.validation { + grid-column: 2; + color: #c0392b; + font-size: 12px; +} + +.actions { + display: flex; + gap: 8px; +} + +.confirm-box { + border: 1px solid #d9d9d9; + border-radius: 4px; + padding: 12px; + background: #fafafa; +} + +.confirm-actions { + display: flex; + gap: 8px; +} diff --git a/desktop/src/app/services/azure-batch/core/batch-http.service.ts b/desktop/src/app/services/azure-batch/core/batch-http.service.ts index c9a5fff560..c4c8622684 100644 --- a/desktop/src/app/services/azure-batch/core/batch-http.service.ts +++ b/desktop/src/app/services/azure-batch/core/batch-http.service.ts @@ -34,42 +34,22 @@ export class AzureBatchHttpService extends HttpService { return this.accountService.currentAccount.pipe( take(1), flatMap((account) => { - const url = this._computeUrl(uri, account); - let obs; - if (account instanceof ArmBatchAccount) { - obs = this._setupRequestForArm(account, options); - } else if (account instanceof LocalBatchAccount) { - obs = this._setupRequestForSharedKey(account, method, url, options); - } else { - throw new InvalidAccountError(`Invalid account type ${account}`); - } - return obs.pipe( - flatMap((options) => { - return super.request( - method, - url, - options).pipe( - retryWhen(attempts => this.retryWhen(attempts)), - catchError((error) => { - if (error.status === 0) { - return throwError(new ServerError({ - status: error.status, - statusText: error.statusText, - message: error.message, - code: error.name, - })); - } - const err = ServerError.fromBatchHttp(error); - return throwError(err); - }), - ); - }), - ); + return this._requestForResolvedAccount(account, method, uri, options); }), shareReplay(1), ); } + /** + * Execute a request against a specific account without mutating global account state. + */ + public requestForAccount(account: BatchAccount, method: any, uri?: any, options?: any): Observable { + options = this._addApiVersion(uri, options); + return this._requestForResolvedAccount(account, method, uri, options).pipe( + shareReplay(1), + ); + } + private _setupRequestForArm(account: ArmBatchAccount, options) { const tenantId = account.subscription.tenantId; return this.auth.accessTokenData(tenantId, "batch").pipe( @@ -82,6 +62,41 @@ export class AzureBatchHttpService extends HttpService { return from(sharedKey.signRequest(method, uri, options)).pipe(map(() => options)); } + private _requestForResolvedAccount(account: BatchAccount, method: any, uri: any, options: any): Observable { + const url = this._computeUrl(uri, account); + let setupRequest: Observable; + if (account instanceof ArmBatchAccount) { + setupRequest = this._setupRequestForArm(account, options); + } else if (account instanceof LocalBatchAccount) { + setupRequest = this._setupRequestForSharedKey(account, method, url, options); + } else { + throw new InvalidAccountError(`Invalid account type ${account}`); + } + + return setupRequest.pipe( + flatMap((setupOptions) => { + return super.request( + method, + url, + setupOptions).pipe( + retryWhen(attempts => this.retryWhen(attempts)), + catchError((error) => { + if (error.status === 0) { + return throwError(new ServerError({ + status: error.status, + statusText: error.statusText, + message: error.message, + code: error.name, + })); + } + const err = ServerError.fromBatchHttp(error); + return throwError(err); + }), + ); + }), + ); + } + private _addApiVersion(uri: string, options: HttpRequestOptions | null): HttpRequestOptions { if (!options) { options = {}; diff --git a/desktop/src/app/services/index.ts b/desktop/src/app/services/index.ts index 7f0fea8e69..80b2d01172 100644 --- a/desktop/src/app/services/index.ts +++ b/desktop/src/app/services/index.ts @@ -34,3 +34,4 @@ export * from "./network"; export * from "./user-configuration"; export * from "./version"; export * from "./safe-storage.service"; +export * from "./workbench"; diff --git a/desktop/src/app/services/workbench/batch-node-actions.service.ts b/desktop/src/app/services/workbench/batch-node-actions.service.ts new file mode 100644 index 0000000000..71de54ecc3 --- /dev/null +++ b/desktop/src/app/services/workbench/batch-node-actions.service.ts @@ -0,0 +1,419 @@ +import { HttpHeaders, HttpParams } from "@angular/common/http"; +import { Injectable } from "@angular/core"; +import { HttpRequestOptions } from "@batch-flask/core"; +import { Node } from "app/models"; +import { AzureBatchHttpService } from "app/services/azure-batch/core"; +import { Observable, from } from "rxjs"; + +export type NodeActionClassification = "quota" | "transient" | "fatal"; + +export type BulkNodeActionKind = + | "removeNodes" + | "rebootNodes" + | "reimageNodes" + | "enableSchedulingNodes" + | "disableSchedulingNodes"; + +export type DisableSchedulingOption = "terminate" | "requeue" | "taskCompletion"; + +export interface NodeActionFailure { + nodeId?: string; + chunkIndex?: number; + status?: number; + code?: string; + message: string; + classification: NodeActionClassification; + attempt: number; +} + +export interface NodeActionResult { + nodeId?: string; + chunkIndex?: number; + success: boolean; + attempts: number; + startedAt: string; + finishedAt: string; + failure?: NodeActionFailure; +} + +export interface BulkNodeActionResult { + action: BulkNodeActionKind; + poolId: string; + totalTargets: number; + successCount: number; + failureCount: number; + startedAt: string; + finishedAt: string; + results: NodeActionResult[]; +} + +interface BatchListResponse { + value: TEntity[]; + "odata.nextLink"?: string; +} + +@Injectable({ providedIn: "root" }) +export class BatchNodeActionsService { + private readonly _maxRemoveNodesPerRequest = 100; + private readonly _maxListPages = 200; + private readonly _retryBackoffSeconds = [2, 4, 8, 16, 32]; + private readonly _retryJitterPercent = 0.2; + + constructor(private http: AzureBatchHttpService) { } + + public listNodes(account: unknown, poolId: string): Observable { + return from(this._listNodes(account, poolId)); + } + + public removeNodes(account: unknown, poolId: string, nodeIds: string[]): Observable { + return from(this._removeNodes(account, poolId, nodeIds)); + } + + public rebootNode(account: unknown, poolId: string, nodeId: string): Observable { + return from(this._nodeAction("rebootNodes", account, poolId, nodeId, () => { + return this._requestWithRetry( + () => this._requestForAccount(account, "POST", `/pools/${poolId}/nodes/${nodeId}/reboot`, { body: null }), + true, + ).then((result) => result.attempts); + })); + } + + public reimageNode(account: unknown, poolId: string, nodeId: string): Observable { + return from(this._nodeAction("reimageNodes", account, poolId, nodeId, () => { + return this._requestWithRetry( + () => this._requestForAccount(account, "POST", `/pools/${poolId}/nodes/${nodeId}/reimage`, { body: null }), + true, + ).then((result) => result.attempts); + })); + } + + public enableScheduling(account: unknown, poolId: string, nodeId: string): Observable { + return from(this._nodeAction("enableSchedulingNodes", account, poolId, nodeId, () => { + return this._requestWithRetry( + () => this._requestForAccount( + account, + "POST", + `/pools/${poolId}/nodes/${nodeId}/enablescheduling`, + { body: null }, + ), + true, + ).then((result) => result.attempts); + })); + } + + public disableScheduling( + account: unknown, + poolId: string, + nodeId: string, + option: DisableSchedulingOption = "taskCompletion", + ): Observable { + return from(this._nodeAction("disableSchedulingNodes", account, poolId, nodeId, () => { + const params = new HttpParams().set("nodeDeallocationOption", option); + return this._requestWithRetry( + () => this._requestForAccount( + account, + "POST", + `/pools/${poolId}/nodes/${nodeId}/disablescheduling`, + { body: null, params }, + ), + true, + ).then((result) => result.attempts); + })); + } + + public rebootNodes(account: unknown, poolId: string, nodeIds: string[]): Observable { + return from(this._runNodeActionInSequence("rebootNodes", account, poolId, nodeIds, (nodeId) => { + return this.rebootNode(account, poolId, nodeId); + })); + } + + public reimageNodes(account: unknown, poolId: string, nodeIds: string[]): Observable { + return from(this._runNodeActionInSequence("reimageNodes", account, poolId, nodeIds, (nodeId) => { + return this.reimageNode(account, poolId, nodeId); + })); + } + + public enableSchedulingNodes(account: unknown, poolId: string, nodeIds: string[]): Observable { + return from(this._runNodeActionInSequence("enableSchedulingNodes", account, poolId, nodeIds, (nodeId) => { + return this.enableScheduling(account, poolId, nodeId); + })); + } + + public disableSchedulingNodes( + account: unknown, + poolId: string, + nodeIds: string[], + option: DisableSchedulingOption = "taskCompletion", + ): Observable { + return from(this._runNodeActionInSequence("disableSchedulingNodes", account, poolId, nodeIds, (nodeId) => { + return this.disableScheduling(account, poolId, nodeId, option); + })); + } + + private async _listNodes(account: unknown, poolId: string): Promise { + const select = "id,state,schedulingState,stateTransitionTime,errors"; + const params = new HttpParams() + .set("$select", select) + .set("maxresults", "1000"); + + let page = 0; + let nextLink: string | undefined = `/pools/${poolId}/nodes`; + const nodes: Node[] = []; + + while (nextLink) { + page += 1; + if (page > this._maxListPages) { + throw new Error(`Node listing exceeded max page limit (${this._maxListPages}) for pool '${poolId}'.`); + } + + const options: HttpRequestOptions = page === 1 ? { params } : {}; + const responseResult = await this._requestWithRetry>( + () => this._requestForAccount>(account, "GET", nextLink, options), + false, + ); + const response = responseResult.result; + + for (const item of response.value || []) { + nodes.push(new Node({ ...item, poolId })); + } + + nextLink = response["odata.nextLink"]; + } + + return nodes; + } + + private async _removeNodes(account: unknown, poolId: string, nodeIds: string[]): Promise { + const startedAt = new Date().toISOString(); + const uniqueNodeIds = Array.from(new Set(nodeIds.filter((x) => Boolean(x)))); + const chunks = this._chunk(uniqueNodeIds, this._maxRemoveNodesPerRequest); + const results: NodeActionResult[] = []; + + for (let index = 0; index < chunks.length; index++) { + const chunk = chunks[index]; + const chunkStartedAt = new Date().toISOString(); + + try { + const requestResult = await this._requestWithRetry( + () => this._requestForAccount( + account, + "POST", + `/pools/${poolId}/removenodes`, + { body: { nodeList: chunk } }, + ), + true, + ); + const attempts = requestResult.attempts; + + results.push({ + chunkIndex: index, + success: true, + attempts, + startedAt: chunkStartedAt, + finishedAt: new Date().toISOString(), + }); + } catch (error) { + const failure = this._toFailure(error, this._attemptFromError(error)); + results.push({ + chunkIndex: index, + success: false, + attempts: this._attemptFromError(error), + startedAt: chunkStartedAt, + finishedAt: new Date().toISOString(), + failure, + }); + } + } + + return this._bulkResult("removeNodes", poolId, uniqueNodeIds.length, startedAt, results); + } + + private async _nodeAction( + _kind: BulkNodeActionKind, + _account: unknown, + _poolId: string, + nodeId: string, + execute: () => Promise, + ): Promise { + const startedAt = new Date().toISOString(); + + try { + const attempts = await execute(); + return { + nodeId, + success: true, + attempts, + startedAt, + finishedAt: new Date().toISOString(), + }; + } catch (error) { + return { + nodeId, + success: false, + attempts: this._attemptFromError(error), + startedAt, + finishedAt: new Date().toISOString(), + failure: this._toFailure(error, this._attemptFromError(error), nodeId), + }; + } + } + + private async _runNodeActionInSequence( + action: BulkNodeActionKind, + account: unknown, + poolId: string, + nodeIds: string[], + callback: (nodeId: string) => Observable, + ): Promise { + const startedAt = new Date().toISOString(); + const uniqueNodeIds = Array.from(new Set(nodeIds.filter((x) => Boolean(x)))); + const results: NodeActionResult[] = []; + + for (const nodeId of uniqueNodeIds) { + const nodeResult = await callback(nodeId).toPromise() as NodeActionResult; + results.push(nodeResult); + } + + return this._bulkResult(action, poolId, uniqueNodeIds.length, startedAt, results); + } + + private _bulkResult( + action: BulkNodeActionKind, + poolId: string, + totalTargets: number, + startedAt: string, + results: NodeActionResult[], + ): BulkNodeActionResult { + const successCount = results.filter((x) => x.success).length; + return { + action, + poolId, + totalTargets, + successCount, + failureCount: results.length - successCount, + startedAt, + finishedAt: new Date().toISOString(), + results, + }; + } + + private async _requestForAccount( + account: unknown, + method: string, + uri: string, + options: HttpRequestOptions = {}, + ): Promise { + const requestForAccount = (this.http as any).requestForAccount; + if (typeof requestForAccount !== "function") { + throw new Error("AzureBatchHttpService.requestForAccount is required for workbench multi-account operations."); + } + return requestForAccount.call(this.http, account, method, uri, options).toPromise(); + } + + private async _requestWithRetry( + request: () => Promise, + retryOnConflict: boolean, + ): Promise<{ result: T, attempts: number }> { + let attempt = 1; + while (attempt <= this._retryBackoffSeconds.length + 1) { + try { + const result = await request(); + return { result, attempts: attempt }; + } catch (error) { + const classification = this._classifyError(error); + const status = this._statusCode(error); + const canRetryStatus = classification === "transient" + || (retryOnConflict && status === 409); + const hasAttemptsLeft = attempt <= this._retryBackoffSeconds.length; + + if (!canRetryStatus || !hasAttemptsLeft) { + (error as any).__attempt = attempt; + throw error; + } + + const delayMs = this._computeRetryDelayMs(error, attempt); + await this._sleep(delayMs); + attempt += 1; + } + } + + throw new Error("Unexpected retry loop termination."); + } + + private _computeRetryDelayMs(error: any, attempt: number): number { + const retryAfter = this._retryAfterMs(error); + if (retryAfter !== null) { + return retryAfter; + } + + const backoffSeconds = this._retryBackoffSeconds[Math.max(0, Math.min(attempt - 1, this._retryBackoffSeconds.length - 1))]; + const jitter = 1 + ((Math.random() * 2 - 1) * this._retryJitterPercent); + return Math.max(250, Math.floor(backoffSeconds * 1000 * jitter)); + } + + private _retryAfterMs(error: any): number | null { + const headers: HttpHeaders = error?.headers; + const retryAfterRaw = headers?.get?.("Retry-After") || headers?.get?.("retry-after"); + if (!retryAfterRaw) { + return null; + } + + if (/^\d+$/.test(retryAfterRaw)) { + return Math.max(250, parseInt(retryAfterRaw, 10) * 1000); + } + + const retryAt = new Date(retryAfterRaw).getTime(); + if (Number.isNaN(retryAt)) { + return null; + } + return Math.max(250, retryAt - Date.now()); + } + + private _statusCode(error: any): number | undefined { + return typeof error?.status === "number" ? error.status : undefined; + } + + private _classifyError(error: any): NodeActionClassification { + const status = this._statusCode(error); + const code = `${error?.code || ""}`.toLowerCase(); + const message = `${error?.message || ""}`.toLowerCase(); + + if (status === 403 || code.includes("quota") || message.includes("quota")) { + return "quota"; + } + + if (status === 0 || status === 408 || status === 409 || status === 429 || status === 500 || status === 502 + || status === 503 || status === 504) { + return "transient"; + } + + return "fatal"; + } + + private _toFailure(error: any, attempt: number, nodeId?: string): NodeActionFailure { + return { + nodeId, + status: this._statusCode(error), + code: error?.code, + message: error?.message || "Unknown error", + classification: this._classifyError(error), + attempt, + }; + } + + private _attemptFromError(error: any): number { + return typeof error?.__attempt === "number" ? error.__attempt : 1; + } + + private _chunk(values: string[], size: number): string[][] { + const chunks: string[][] = []; + for (let index = 0; index < values.length; index += size) { + chunks.push(values.slice(index, index + size)); + } + return chunks; + } + + private async _sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/desktop/src/app/services/workbench/batch-node-actions.spec.ts b/desktop/src/app/services/workbench/batch-node-actions.spec.ts new file mode 100644 index 0000000000..d5252ff8b0 --- /dev/null +++ b/desktop/src/app/services/workbench/batch-node-actions.spec.ts @@ -0,0 +1,28 @@ +function chunkNodeIds(nodeIds: string[], maxPerRequest = 100): string[][] { + const chunks: string[][] = []; + for (let i = 0; i < nodeIds.length; i += maxPerRequest) { + chunks.push(nodeIds.slice(i, i + maxPerRequest)); + } + return chunks; +} + +describe("BatchNodeActions chunking safety", () => { + it("never generates a chunk above 100 node IDs", () => { + const ids = Array.from({ length: 245 }, (_, i) => `node-${i}`); + const chunks = chunkNodeIds(ids, 100); + + expect(chunks.length).toBe(3); + expect(chunks[0].length).toBe(100); + expect(chunks[1].length).toBe(100); + expect(chunks[2].length).toBe(45); + expect(chunks.every(x => x.length <= 100)).toBe(true); + }); + + it("keeps ordering stable across chunks", () => { + const ids = ["n-1", "n-2", "n-3", "n-4"]; + const chunks = chunkNodeIds(ids, 2); + const flattened = chunks.reduce((acc, chunk) => acc.concat(chunk), [] as string[]); + + expect(flattened).toEqual(ids); + }); +}); diff --git a/desktop/src/app/services/workbench/batch-pool-actions.service.ts b/desktop/src/app/services/workbench/batch-pool-actions.service.ts new file mode 100644 index 0000000000..8014454809 --- /dev/null +++ b/desktop/src/app/services/workbench/batch-pool-actions.service.ts @@ -0,0 +1,396 @@ +import { Injectable } from "@angular/core"; +import { HttpMethod, HttpRequestOptions } from "@batch-flask/core"; +import { BatchAccount } from "app/models"; +import { NodeDeallocationOption } from "app/models/dtos"; +import { AzureBatchHttpService } from "app/services/azure-batch/core"; +import { Observable, from } from "rxjs"; + +export type PoolActionErrorKind = "quota" | "transient" | "fatal"; + +export interface BatchPoolActionErrorAttributes { + action: string; + kind: PoolActionErrorKind; + accountId: string; + poolId?: string; + attempt: number; + status?: number; + code?: string; + details?: string; + originalError: any; +} + +export class BatchPoolActionError extends Error { + public action: string; + public kind: PoolActionErrorKind; + public accountId: string; + public poolId?: string; + public attempt: number; + public status?: number; + public code?: string; + public details?: string; + public originalError: any; + + constructor(attributes: BatchPoolActionErrorAttributes) { + super(attributes.details || `Pool action '${attributes.action}' failed`); + this.name = "BatchPoolActionError"; + this.action = attributes.action; + this.kind = attributes.kind; + this.accountId = attributes.accountId; + this.poolId = attributes.poolId; + this.attempt = attributes.attempt; + this.status = attributes.status; + this.code = attributes.code; + this.details = attributes.details; + this.originalError = attributes.originalError; + } +} + +const DEFAULT_CONCURRENCY = 1; +const DEFAULT_DELAY_BETWEEN_REQUESTS_MS = 250; +const DEFAULT_MAX_ATTEMPTS = 5; +const DEFAULT_BACKOFF_SECONDS = [2, 4, 8, 16, 32]; + +@Injectable({ providedIn: "root" }) +export class BatchPoolActionsService { + private _activeTasks = 0; + private _pendingSlots: Array<() => void> = []; + private _accountQueues = new Map>(); + + constructor(private batchHttp: AzureBatchHttpService) { + } + + public resizePool( + account: BatchAccount, + poolId: string, + targetDedicatedNodes: number, + deallocationOption: NodeDeallocationOption = NodeDeallocationOption.requeue): Observable { + + const body = { + targetDedicatedNodes, + nodeDeallocationOption: deallocationOption, + }; + return from(this._schedulePoolAction(account, "resizePool", poolId, true, () => { + return this._requestForAccount(account, HttpMethod.Post, `/pools/${poolId}/resize`, { body }); + })); + } + + public stopResize(account: BatchAccount, poolId: string): Observable { + return from(this._schedulePoolAction(account, "stopResize", poolId, true, () => { + return this._requestForAccount(account, HttpMethod.Post, `/pools/${poolId}/stopresize`, { body: null }); + })); + } + + public deletePool(account: BatchAccount, poolId: string): Observable { + return from(this._schedulePoolAction(account, "deletePool", poolId, false, () => { + return this._requestForAccount(account, HttpMethod.Delete, `/pools/${poolId}`); + })); + } + + public exportPoolJson(account: BatchAccount, poolId: string): Observable { + return from(this._schedulePoolAction(account, "exportPoolJson", poolId, false, () => { + return this._requestForAccount(account, HttpMethod.Get, `/pools/${poolId}`); + })); + } + + public clonePool(account: BatchAccount, sourcePoolId: string, newPoolId: string): Observable { + return from(this._schedulePoolAction(account, "clonePool", sourcePoolId, false, async () => { + const sourcePool = await this._requestForAccount(account, HttpMethod.Get, `/pools/${sourcePoolId}`); + const body = this._buildPoolCreatePayload(sourcePool, newPoolId); + return this._requestForAccount(account, HttpMethod.Post, "/pools", { body }); + })); + } + + public recreatePool(account: BatchAccount, poolId: string, newPoolId?: string): Observable { + return from(this._schedulePoolAction(account, "recreatePool", poolId, false, async () => { + const sourcePool = await this._requestForAccount(account, HttpMethod.Get, `/pools/${poolId}`); + const targetPoolId = newPoolId || poolId; + const body = this._buildPoolCreatePayload(sourcePool, targetPoolId); + await this._requestForAccount(account, HttpMethod.Delete, `/pools/${poolId}`); + return this._requestForAccount(account, HttpMethod.Post, "/pools", { body }); + })); + } + + private _schedulePoolAction( + account: BatchAccount, + actionName: string, + poolId: string | undefined, + retryOnConflict: boolean, + operation: () => Promise): Promise { + + const key = this._getAccountKey(account); + const previous = this._accountQueues.get(key) || Promise.resolve(); + const task = previous + .catch(() => { + // Ensure one failed task does not block subsequent actions for the same account. + }) + .then(() => this._withGlobalSlot(() => { + return this._runWithRetry(account, actionName, poolId, retryOnConflict, operation); + })); + + this._accountQueues.set(key, task.then(() => undefined, () => undefined)); + return task; + } + + private async _withGlobalSlot(task: () => Promise): Promise { + await this._acquireSlot(); + try { + return await task(); + } finally { + await this._delay(DEFAULT_DELAY_BETWEEN_REQUESTS_MS); + this._releaseSlot(); + } + } + + private _acquireSlot(): Promise { + if (this._activeTasks < DEFAULT_CONCURRENCY) { + this._activeTasks += 1; + return Promise.resolve(); + } + + return new Promise((resolve) => { + this._pendingSlots.push(() => { + this._activeTasks += 1; + resolve(); + }); + }); + } + + private _releaseSlot() { + this._activeTasks = Math.max(0, this._activeTasks - 1); + const next = this._pendingSlots.shift(); + if (next) { + next(); + } + } + + private async _runWithRetry( + account: BatchAccount, + actionName: string, + poolId: string | undefined, + retryOnConflict: boolean, + operation: () => Promise): Promise { + + for (let attempt = 1; attempt <= DEFAULT_MAX_ATTEMPTS; attempt++) { + try { + return await operation(); + } catch (error) { + const status = this._readStatus(error); + const code = this._readCode(error); + const kind = this._classifyError(status, code, retryOnConflict); + const canRetry = this._canRetry(kind, status, attempt, retryOnConflict); + + if (!canRetry) { + throw this._toActionError(account, actionName, poolId, attempt, kind, error); + } + + const retryDelayMs = this._resolveRetryDelayMs(error, attempt); + await this._delay(retryDelayMs); + } + } + + throw this._toActionError( + account, + actionName, + poolId, + DEFAULT_MAX_ATTEMPTS, + "fatal", + new Error(`Action ${actionName} exceeded max attempts (${DEFAULT_MAX_ATTEMPTS})`), + ); + } + + private _canRetry( + kind: PoolActionErrorKind, + status: number | undefined, + attempt: number, + retryOnConflict: boolean): boolean { + + if (attempt >= DEFAULT_MAX_ATTEMPTS) { + return false; + } + + if (kind !== "transient") { + return false; + } + + if (status === 409) { + return retryOnConflict; + } + + return true; + } + + private _classifyError( + status: number | undefined, + code: string | undefined, + retryOnConflict: boolean): PoolActionErrorKind { + + const normalizedCode = (code || "").toLowerCase(); + + if (status === 403 || normalizedCode.includes("quota")) { + return "quota"; + } + + if (status === 409) { + return retryOnConflict ? "transient" : "fatal"; + } + + if (status === 0 || status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504) { + return "transient"; + } + + return "fatal"; + } + + private _resolveRetryDelayMs(error: any, attempt: number): number { + const retryAfter = this._readRetryAfterMs(error); + if (retryAfter !== null) { + return retryAfter; + } + + const delaySeconds = DEFAULT_BACKOFF_SECONDS[Math.min(attempt - 1, DEFAULT_BACKOFF_SECONDS.length - 1)]; + return Math.max(1000, delaySeconds * 1000); + } + + private _readRetryAfterMs(error: any): number | null { + const headers = error && error.headers; + if (!headers || typeof headers.get !== "function") { + return null; + } + + const retryAfter = headers.get("Retry-After") || headers.get("retry-after"); + if (!retryAfter) { + return null; + } + + const asNumber = Number(retryAfter); + if (!Number.isNaN(asNumber)) { + return Math.max(0, Math.floor(asNumber * 1000)); + } + + const asDate = Date.parse(retryAfter); + if (Number.isNaN(asDate)) { + return null; + } + + return Math.max(0, asDate - Date.now()); + } + + private _toActionError( + account: BatchAccount, + actionName: string, + poolId: string | undefined, + attempt: number, + kind: PoolActionErrorKind, + error: any): BatchPoolActionError { + + return new BatchPoolActionError({ + action: actionName, + kind, + accountId: this._getAccountKey(account), + poolId, + attempt, + status: this._readStatus(error), + code: this._readCode(error), + details: this._readMessage(error), + originalError: error, + }); + } + + private _readStatus(error: any): number | undefined { + if (error && typeof error.status === "number") { + return error.status; + } + return undefined; + } + + private _readCode(error: any): string | undefined { + return error && error.code ? `${error.code}` : undefined; + } + + private _readMessage(error: any): string | undefined { + if (!error) { + return undefined; + } + if (error.message) { + return error.message; + } + if (error.error && error.error.message && error.error.message.value) { + return error.error.message.value; + } + if (error.statusText) { + return error.statusText; + } + return undefined; + } + + private _buildPoolCreatePayload(sourcePool: any, newPoolId: string): any { + const payload: any = { + id: newPoolId, + }; + + const copyFields = [ + "displayName", + "vmSize", + "cloudServiceConfiguration", + "virtualMachineConfiguration", + "networkConfiguration", + "resizeTimeout", + "targetDedicatedNodes", + "targetLowPriorityNodes", + "taskSlotsPerNode", + "taskSchedulingPolicy", + "autoScaleFormula", + "autoScaleEvaluationInterval", + "enableAutoScale", + "enableInterNodeCommunication", + "startTask", + "certificateReferences", + "applicationPackageReferences", + "metadata", + "userAccounts", + "applicationLicenses", + "targetNodeCommunicationMode", + "identity", + ]; + + for (const field of copyFields) { + if (sourcePool && sourcePool[field] !== undefined) { + payload[field] = sourcePool[field]; + } + } + + return payload; + } + + private _getAccountKey(account: BatchAccount): string { + return account && account.id ? account.id : `${account && account.url ? account.url : "unknown-account"}`; + } + + private _delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + private _requestForAccount( + account: BatchAccount, + method: HttpMethod | string, + uri: string, + options?: HttpRequestOptions): Promise { + + const accountAwareRequest = (this.batchHttp as any).requestForAccount; + if (typeof accountAwareRequest !== "function") { + return Promise.reject(new BatchPoolActionError({ + action: "requestForAccount", + kind: "fatal", + accountId: this._getAccountKey(account), + attempt: 1, + details: "AzureBatchHttpService.requestForAccount is unavailable", + originalError: new Error("Missing requestForAccount"), + })); + } + return this._toPromise(accountAwareRequest.call(this.batchHttp, account, method, uri, options)); + } + + private _toPromise(obs: Observable): Promise { + return obs.toPromise(); + } +} diff --git a/desktop/src/app/services/workbench/index.ts b/desktop/src/app/services/workbench/index.ts new file mode 100644 index 0000000000..c19319bee3 --- /dev/null +++ b/desktop/src/app/services/workbench/index.ts @@ -0,0 +1,6 @@ +export * from "./request-scheduler"; +export * from "./workbench-types"; +export * from "./workbench-discovery.service"; +export * from "./batch-pool-actions.service"; +export * from "./batch-node-actions.service"; +export * from "./start-task-apply.service"; diff --git a/desktop/src/app/services/workbench/request-scheduler.spec.ts b/desktop/src/app/services/workbench/request-scheduler.spec.ts new file mode 100644 index 0000000000..dba915939c --- /dev/null +++ b/desktop/src/app/services/workbench/request-scheduler.spec.ts @@ -0,0 +1,75 @@ +import { RequestScheduler } from "./request-scheduler"; + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +describe("RequestScheduler", () => { + function createScheduler(overrides: any = {}) { + const SchedulerCtor: any = RequestScheduler as any; + return new SchedulerCtor({ + concurrency: 1, + delayMs: 0, + retryAttempts: 5, + backoffSeconds: [0, 0, 0, 0, 0], + jitterPct: 0, + ...overrides, + }); + } + + it("enforces global concurrency", async () => { + const scheduler: any = createScheduler({ concurrency: 1 }); + + let active = 0; + let maxActive = 0; + const runTask = async () => scheduler.run("account-a", async () => { + active++; + maxActive = Math.max(maxActive, active); + await delay(15); + active--; + return true; + }); + + await Promise.all([runTask(), runTask(), runTask()]); + expect(maxActive).toBe(1); + }); + + it("serializes operations with the same key", async () => { + const scheduler: any = createScheduler({ concurrency: 3 }); + + let sameKeyActive = 0; + let sameKeyMax = 0; + + const runTask = async () => scheduler.run("shared-account", async () => { + sameKeyActive++; + sameKeyMax = Math.max(sameKeyMax, sameKeyActive); + await delay(10); + sameKeyActive--; + return true; + }); + + await Promise.all([runTask(), runTask(), runTask()]); + expect(sameKeyMax).toBe(1); + }); + + it("retries transient throttling errors", async () => { + const scheduler: any = createScheduler({ + retryAttempts: 3, + backoffSeconds: [0, 0, 0], + }); + let attempts = 0; + + const result = await scheduler.run("account-a", async () => { + attempts++; + if (attempts < 3) { + const error: any = new Error("throttled"); + error.status = 429; + throw error; + } + return "ok"; + }); + + expect(result).toBe("ok"); + expect(attempts).toBe(3); + }); +}); diff --git a/desktop/src/app/services/workbench/request-scheduler.ts b/desktop/src/app/services/workbench/request-scheduler.ts new file mode 100644 index 0000000000..63e80c3304 --- /dev/null +++ b/desktop/src/app/services/workbench/request-scheduler.ts @@ -0,0 +1,219 @@ +export interface RequestSchedulerOptions { + concurrency?: number; + delayMs?: number; + retryAttempts?: number; + backoffSeconds?: number[]; + jitterPct?: number; +} + +export interface RetryDecision { + shouldRetry: boolean; + reason: "none" | "network" | "throttle" | "transient" | "conflict"; + delayMs: number; +} + +const DEFAULT_RETRY_BACKOFF_SECONDS = [2, 4, 8, 16, 32]; + +/** + * Schedules requests with per-key serialization and bounded global concurrency. + * Default behavior is conservative to reduce throttling risk. + */ +export class RequestScheduler { + private readonly _concurrency: number; + private readonly _delayMs: number; + private readonly _retryAttempts: number; + private readonly _backoffSeconds: number[]; + private readonly _jitterPct: number; + + private _activeCount = 0; + private _nextStartAt = 0; + private _paceChain: Promise = Promise.resolve(); + private _keyChains = new Map>(); + private _slotQueue: Array<() => void> = []; + + constructor(options: RequestSchedulerOptions = {}) { + this._concurrency = Math.max(1, options.concurrency ?? 1); + this._delayMs = Math.max(0, options.delayMs ?? 250); + this._retryAttempts = Math.max(0, options.retryAttempts ?? 5); + this._backoffSeconds = options.backoffSeconds && options.backoffSeconds.length > 0 + ? options.backoffSeconds + : DEFAULT_RETRY_BACKOFF_SECONDS; + this._jitterPct = Math.min(Math.max(options.jitterPct ?? 0.1, 0), 0.5); + } + + public run(key: string, fn: () => Promise): Promise { + const serializedKey = key || "default"; + const previous = this._keyChains.get(serializedKey) ?? Promise.resolve(); + const scheduled = previous + .catch(() => undefined) + .then(() => this._executeScheduled(fn)); + + this._keyChains.set(serializedKey, scheduled); + scheduled.finally(() => { + if (this._keyChains.get(serializedKey) === scheduled) { + this._keyChains.delete(serializedKey); + } + }); + + return scheduled; + } + + private async _executeScheduled(fn: () => Promise): Promise { + await this._acquireSlot(); + try { + await this._applyPacing(); + return await this._executeWithRetry(fn); + } finally { + this._releaseSlot(); + } + } + + private async _executeWithRetry(fn: () => Promise): Promise { + let retryCount = 0; + for (;;) { + try { + return await fn(); + } catch (error) { + const decision = this._classifyRetry(error, retryCount); + if (!decision.shouldRetry || retryCount >= this._retryAttempts) { + throw error; + } + retryCount++; + await this._delay(decision.delayMs); + } + } + } + + private _classifyRetry(error: any, retryCount: number): RetryDecision { + const status = this._extractStatus(error); + const retryAfterMs = this._extractRetryAfterMs(error); + const baseDelayMs = this._getBackoffDelayMs(retryCount); + const delayMs = Math.max(baseDelayMs, retryAfterMs ?? 0); + + if (status === 0 || status == null) { + return { shouldRetry: true, reason: "network", delayMs }; + } + + if (status === 429) { + return { shouldRetry: true, reason: "throttle", delayMs }; + } + + if (status === 503 || status === 502 || status === 500 || status === 408) { + return { shouldRetry: true, reason: "transient", delayMs }; + } + + if (status === 409 && this._isRetryableConflict(error)) { + return { shouldRetry: true, reason: "conflict", delayMs }; + } + + return { shouldRetry: false, reason: "none", delayMs: 0 }; + } + + private _isRetryableConflict(error: any): boolean { + const code = String(error?.code ?? error?.error?.code ?? "").toLowerCase(); + const message = String(error?.message ?? error?.error?.message ?? "").toLowerCase(); + const retriableHints = [ + "poolisresizing", + "operationinvalidforcurrentstate", + "anotheroperation", + "conflict", + "allocation state", + "busy", + "resizing", + "stopping", + "steady", + ]; + + return retriableHints.some(x => code.includes(x) || message.includes(x)); + } + + private _extractStatus(error: any): number | null { + const status = error?.status; + return typeof status === "number" ? status : null; + } + + private _extractRetryAfterMs(error: any): number | null { + const headers = error?.headers; + if (!headers) { + return null; + } + + let value: string | null = null; + if (typeof headers.get === "function") { + value = headers.get("Retry-After") ?? headers.get("retry-after"); + } else { + value = headers["Retry-After"] ?? headers["retry-after"] ?? null; + } + + if (!value) { + return null; + } + + const asNumber = Number(value); + if (!Number.isNaN(asNumber)) { + return Math.max(0, Math.floor(asNumber * 1000)); + } + + const asDate = Date.parse(value); + if (Number.isNaN(asDate)) { + return null; + } + + return Math.max(0, asDate - Date.now()); + } + + private _getBackoffDelayMs(retryCount: number): number { + const index = Math.min(retryCount, this._backoffSeconds.length - 1); + const baseMs = this._backoffSeconds[index] * 1000; + if (this._jitterPct <= 0) { + return baseMs; + } + + const spread = baseMs * this._jitterPct; + const jitter = (Math.random() * spread * 2) - spread; + return Math.max(0, Math.floor(baseMs + jitter)); + } + + private async _acquireSlot(): Promise { + if (this._activeCount < this._concurrency) { + this._activeCount++; + return; + } + + await new Promise((resolve) => { + this._slotQueue.push(() => { + this._activeCount++; + resolve(); + }); + }); + } + + private _releaseSlot(): void { + this._activeCount = Math.max(0, this._activeCount - 1); + const next = this._slotQueue.shift(); + if (next) { + next(); + } + } + + private async _applyPacing(): Promise { + const nextPace = this._paceChain.then(async () => { + const now = Date.now(); + const waitMs = Math.max(0, this._nextStartAt - now); + if (waitMs > 0) { + await this._delay(waitMs); + } + this._nextStartAt = Date.now() + this._delayMs; + }); + + this._paceChain = nextPace.catch(() => undefined); + await nextPace; + } + + private _delay(ms: number): Promise { + if (ms <= 0) { + return Promise.resolve(); + } + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/desktop/src/app/services/workbench/start-task-apply.service.ts b/desktop/src/app/services/workbench/start-task-apply.service.ts new file mode 100644 index 0000000000..b6a8376f62 --- /dev/null +++ b/desktop/src/app/services/workbench/start-task-apply.service.ts @@ -0,0 +1,303 @@ +import { Injectable, Optional } from "@angular/core"; +import { BatchAccount } from "app/models"; +import { StartTaskDto } from "app/models/dtos"; +import { AzureBatchHttpService } from "app/services/azure-batch/core"; +import { BatchAccountService } from "app/services/batch-account"; +import { Observable, Subscriber } from "rxjs"; +import { RequestScheduler } from "./request-scheduler"; + +export type StartTaskApplyScope = "current" | "selected" | "all"; + +export interface StartTaskApplyTarget { + subscriptionId: string; + accountId: string; + accountName: string; + location: string; + poolId: string; +} + +export interface StartTaskApplyRequest { + scope: StartTaskApplyScope; + startTask: Partial; + currentTarget?: StartTaskApplyTarget; + selectedTargets?: StartTaskApplyTarget[]; + allTargets?: StartTaskApplyTarget[]; + dryRun?: boolean; + confirmationAccepted?: boolean; +} + +export interface StartTaskApplyPreview { + scope: StartTaskApplyScope; + totalTargets: number; + targets: StartTaskApplyTarget[]; + validationErrors: string[]; +} + +export type StartTaskApplyErrorKind = "quota" | "transient" | "fatal"; + +export interface StartTaskApplyResult { + target: StartTaskApplyTarget; + status: "applied" | "skipped" | "failed"; + errorKind?: StartTaskApplyErrorKind; + stopReason?: string; + retries: number; + startedAt: Date; + finishedAt: Date; +} + +export interface StartTaskApplySummary { + scope: StartTaskApplyScope; + total: number; + succeeded: number; + failed: number; + skipped: number; + startedAt: Date; + finishedAt: Date; + results: StartTaskApplyResult[]; +} + +export interface StartTaskApplyProgress { + stage: "started" | "running" | "completed"; + totalTargets: number; + completedTargets: number; + currentTarget?: StartTaskApplyTarget; + lastResult?: StartTaskApplyResult; + summary?: StartTaskApplySummary; +} + +@Injectable({ providedIn: "root" }) +export class StartTaskApplyService { + private scheduler: RequestScheduler; + + constructor( + private accountService: BatchAccountService, + private batchHttp: AzureBatchHttpService, + @Optional() scheduler?: RequestScheduler) { + this.scheduler = scheduler || new RequestScheduler(); + } + + public preview(request: StartTaskApplyRequest): StartTaskApplyPreview { + const targets = this._resolveTargets(request); + const validationErrors = this._validateRequest(request, targets); + return { + scope: request.scope, + totalTargets: targets.length, + targets, + validationErrors, + }; + } + + public applyStartTask(request: StartTaskApplyRequest): Observable { + return new Observable((subscriber) => { + void this._executeApply(request, subscriber); + }); + } + + private async _executeApply( + request: StartTaskApplyRequest, + subscriber: Subscriber) { + const startedAt = new Date(); + const preview = this.preview(request); + + if (preview.validationErrors.length > 0) { + subscriber.error(new Error(preview.validationErrors.join("\n"))); + return; + } + + subscriber.next({ + stage: "started", + totalTargets: preview.totalTargets, + completedTargets: 0, + }); + + const results: StartTaskApplyResult[] = []; + let completedTargets = 0; + + if (request.dryRun) { + for (const target of preview.targets) { + const now = new Date(); + const skippedResult: StartTaskApplyResult = { + target, + status: "skipped", + stopReason: "dryRun", + retries: 0, + startedAt: now, + finishedAt: now, + }; + results.push(skippedResult); + completedTargets++; + subscriber.next({ + stage: "running", + totalTargets: preview.totalTargets, + completedTargets, + currentTarget: target, + lastResult: skippedResult, + }); + } + + subscriber.next({ + stage: "completed", + totalTargets: preview.totalTargets, + completedTargets, + summary: this._buildSummary(request.scope, results, startedAt), + }); + subscriber.complete(); + return; + } + + for (const target of preview.targets) { + const opStarted = new Date(); + try { + await this._applyToTarget(target, request.startTask); + const success: StartTaskApplyResult = { + target, + status: "applied", + retries: 0, + startedAt: opStarted, + finishedAt: new Date(), + }; + results.push(success); + completedTargets++; + subscriber.next({ + stage: "running", + totalTargets: preview.totalTargets, + completedTargets, + currentTarget: target, + lastResult: success, + }); + } catch (error) { + const failed: StartTaskApplyResult = { + target, + status: "failed", + errorKind: this._classifyError(error), + stopReason: this._extractErrorMessage(error), + retries: 0, + startedAt: opStarted, + finishedAt: new Date(), + }; + results.push(failed); + completedTargets++; + subscriber.next({ + stage: "running", + totalTargets: preview.totalTargets, + completedTargets, + currentTarget: target, + lastResult: failed, + }); + } + } + + subscriber.next({ + stage: "completed", + totalTargets: preview.totalTargets, + completedTargets, + summary: this._buildSummary(request.scope, results, startedAt), + }); + subscriber.complete(); + } + + private _buildSummary( + scope: StartTaskApplyScope, + results: StartTaskApplyResult[], + startedAt: Date): StartTaskApplySummary { + + const succeeded = results.filter(x => x.status === "applied").length; + const failed = results.filter(x => x.status === "failed").length; + const skipped = results.filter(x => x.status === "skipped").length; + return { + scope, + total: results.length, + succeeded, + failed, + skipped, + startedAt, + finishedAt: new Date(), + results, + }; + } + + private _resolveTargets(request: StartTaskApplyRequest): StartTaskApplyTarget[] { + if (request.scope === "current") { + return request.currentTarget ? [request.currentTarget] : []; + } + if (request.scope === "selected") { + return request.selectedTargets || []; + } + return request.allTargets || []; + } + + private _validateRequest(request: StartTaskApplyRequest, targets: StartTaskApplyTarget[]): string[] { + const errors: string[] = []; + const commandLine = request.startTask && request.startTask.commandLine; + if (!commandLine || commandLine.trim().length === 0) { + errors.push("Start task commandLine is required."); + } + if (!request.confirmationAccepted && !request.dryRun) { + errors.push("Confirmation is required before applying start task changes."); + } + if (targets.length === 0) { + errors.push("No target pools selected for apply scope."); + } + return errors; + } + + private async _applyToTarget(target: StartTaskApplyTarget, startTask: Partial) { + const account = await this.accountService.get(target.accountId).toPromise(); + if (!account) { + throw new Error(`Batch account ${target.accountId} not found.`); + } + + const requestForAccount = (this.batchHttp as any).requestForAccount; + if (typeof requestForAccount !== "function") { + throw new Error("AzureBatchHttpService.requestForAccount is not available."); + } + + await this.scheduler.run(target.accountId, async () => { + await requestForAccount.call( + this.batchHttp, + account as BatchAccount, + "PATCH", + `/pools/${encodeURIComponent(target.poolId)}`, + { body: this._buildPatchBody(startTask) }, + ).toPromise(); + }); + } + + private _buildPatchBody(startTask: Partial) { + // Use patch semantics with startTask only to avoid accidental removal of other pool settings. + return { + startTask: { + commandLine: startTask.commandLine && startTask.commandLine.trim(), + waitForSuccess: startTask.waitForSuccess, + maxTaskRetryCount: startTask.maxTaskRetryCount, + resourceFiles: startTask.resourceFiles || [], + environmentSettings: startTask.environmentSettings || [], + userIdentity: startTask.userIdentity, + containerSettings: startTask.containerSettings, + }, + }; + } + + private _classifyError(error: any): StartTaskApplyErrorKind { + const status = error && (error.status as number); + const code = String(error && (error.code || "")).toLowerCase(); + const message = String(error && (error.message || "")).toLowerCase(); + + if (status === 409 || status === 429 || status === 408 || (status >= 500 && status <= 599)) { + return "transient"; + } + + if (code.includes("quota") || message.includes("quota")) { + return "quota"; + } + + return "fatal"; + } + + private _extractErrorMessage(error: any): string { + if (!error) { + return "Unknown error."; + } + return error.message || error.statusText || error.code || "Unknown error."; + } +} diff --git a/desktop/src/app/services/workbench/start-task-apply.spec.ts b/desktop/src/app/services/workbench/start-task-apply.spec.ts new file mode 100644 index 0000000000..e98a5de44f --- /dev/null +++ b/desktop/src/app/services/workbench/start-task-apply.spec.ts @@ -0,0 +1,111 @@ +import { of } from "rxjs"; +import { + StartTaskApplyRequest, + StartTaskApplyService, + StartTaskApplyTarget, +} from "./start-task-apply.service"; + +describe("StartTaskApplyService", () => { + function createTarget(index = 1): StartTaskApplyTarget { + return { + subscriptionId: "sub-1", + accountId: "account-1", + accountName: "account-name", + location: "eastus", + poolId: `pool-${index}`, + }; + } + + function createService(overrides: any = {}) { + const scheduler = { + run: jasmine.createSpy("run").and.callFake(async (_key: string, callback: () => Promise) => { + return callback(); + }), + ...(overrides.scheduler || {}), + }; + + const accountService = { + get: jasmine.createSpy("get").and.callFake(() => of({ + id: "account-1", + name: "account-name", + })), + ...(overrides.accountService || {}), + }; + + const batchHttp = { + requestForAccount: jasmine.createSpy("requestForAccount").and.callFake(() => of({})), + ...(overrides.batchHttp || {}), + }; + + const service = new StartTaskApplyService( + accountService as any, + batchHttp as any, + scheduler as any, + ); + + return { service, scheduler, accountService, batchHttp }; + } + + async function collectEvents(observable: any): Promise { + return new Promise((resolve, reject) => { + const events: any[] = []; + observable.subscribe({ + next: (x) => events.push(x), + error: reject, + complete: () => resolve(events), + }); + }); + } + + it("validates that commandLine is required", () => { + const { service } = createService(); + const preview = service.preview({ + scope: "current", + startTask: { commandLine: " " }, + currentTarget: createTarget(), + confirmationAccepted: true, + }); + + expect(preview.validationErrors).toContain("Start task commandLine is required."); + }); + + it("supports dry-run preview execution without writes", async () => { + const { service, scheduler, batchHttp } = createService(); + const events = await collectEvents(service.applyStartTask({ + scope: "current", + dryRun: true, + startTask: { commandLine: "cmd /c echo test" }, + currentTarget: createTarget(), + confirmationAccepted: true, + })); + + expect(events.length).toBe(3); + expect(events[0].stage).toBe("started"); + expect(events[1].lastResult.status).toBe("skipped"); + expect(events[2].summary.skipped).toBe(1); + expect(scheduler.run).not.toHaveBeenCalled(); + expect(batchHttp.requestForAccount).not.toHaveBeenCalled(); + }); + + it("applies start task to selected targets sequentially via scheduler", async () => { + const { service, scheduler, batchHttp } = createService(); + const request: StartTaskApplyRequest = { + scope: "selected", + startTask: { commandLine: "cmd /c setup.bat", waitForSuccess: true }, + selectedTargets: [createTarget(1), createTarget(2)], + confirmationAccepted: true, + }; + + const events = await collectEvents(service.applyStartTask(request)); + const completed = events[events.length - 1]; + + expect(scheduler.run).toHaveBeenCalledTimes(2); + expect(batchHttp.requestForAccount).toHaveBeenCalledTimes(2); + expect(batchHttp.requestForAccount.calls.mostRecent().args[1]).toBe("PATCH"); + expect(batchHttp.requestForAccount.calls.mostRecent().args[3].body.startTask.commandLine) + .toBe("cmd /c setup.bat"); + expect(completed.stage).toBe("completed"); + expect(completed.summary.succeeded).toBe(2); + expect(completed.summary.failed).toBe(0); + }); +}); diff --git a/desktop/src/app/services/workbench/workbench-discovery.service.ts b/desktop/src/app/services/workbench/workbench-discovery.service.ts new file mode 100644 index 0000000000..7a01f5726b --- /dev/null +++ b/desktop/src/app/services/workbench/workbench-discovery.service.ts @@ -0,0 +1,364 @@ +import { HttpParams } from "@angular/common/http"; +import { Injectable } from "@angular/core"; +import { HttpCode } from "@batch-flask/core"; +import { log } from "@batch-flask/utils"; +import { ArmBatchAccount, BatchAccount, LocalBatchAccount, PoolAllocationState } from "app/models"; +import { AzureBatchHttpService, BatchListResponse } from "app/services/azure-batch/core"; +import { ArmBatchAccountService, BatchAccountService, LocalBatchAccountService } from "app/services/batch-account"; +import { SubscriptionService } from "app/services/subscription"; +import { from, Observable, of } from "rxjs"; +import { catchError, concatMap, expand, map, reduce, switchMap, take } from "rxjs/operators"; + +export type WorkbenchErrorClass = "quota" | "transient" | "fatal"; + +export interface WorkbenchAccountRef { + subscriptionId: string | null; + accountId: string; + accountName: string; + location: string | null; + endpoint: string; +} + +export interface WorkbenchNodeCountsByState { + creating: number; + idle: number; + leavingPool: number; + offline: number; + preempted: number; + rebooting: number; + reimaging: number; + running: number; + startTaskFailed: number; + starting: number; + total: number; + unknown: number; + unusable: number; + waitingForStartTask: number; +} + +export interface WorkbenchPoolRow { + subscriptionId: string | null; + accountId: string; + accountName: string; + location: string | null; + poolId: string; + allocationState: string | null; + nodeCountsByState: WorkbenchNodeCountsByState; + alerts: string[]; +} + +export interface WorkbenchClassifiedError { + category: WorkbenchErrorClass; + message: string; + status?: number; +} + +interface BatchPoolSummary { + id: string; + allocationState?: string; +} + +interface BatchPoolNodeCounts { + poolId: string; + dedicated: WorkbenchNodeCountsByState; + lowPriority: WorkbenchNodeCountsByState; +} + +interface QuotaStatus { + used: number; + quota: number; +} + +@Injectable({ providedIn: "root" }) +export class WorkbenchDiscoveryService { + constructor( + private subscriptionService: SubscriptionService, + private batchAccountService: BatchAccountService, + private armBatchAccountService: ArmBatchAccountService, + private localBatchAccountService: LocalBatchAccountService, + private batchHttp: AzureBatchHttpService, + ) { + } + + public listAccounts(): Observable { + return this._loadAccounts().pipe( + map((accounts) => { + return accounts.map((account) => this._toAccountRef(account)) + .sort((left, right) => left.accountName.localeCompare(right.accountName)); + }), + ); + } + + public listPools(accountRef: WorkbenchAccountRef): Observable { + return this._resolveAccount(accountRef).pipe( + switchMap((account) => this._listPoolsForAccount(account)), + catchError((error) => { + const classified = this.classifyError(error); + log.error(`[WorkbenchDiscovery] listPools failed for ${accountRef.accountId}`, classified, error); + return of([]); + }), + ); + } + + public getPoolNodeCounts(accountRef: WorkbenchAccountRef, poolId: string): Observable { + return this._resolveAccount(accountRef).pipe( + switchMap((account) => this._getAllPoolNodeCounts(account)), + map((countsByPool) => { + return countsByPool.get(poolId) || this._emptyNodeCounts(); + }), + catchError((error) => { + const classified = this.classifyError(error); + log.error(`[WorkbenchDiscovery] getPoolNodeCounts failed for ${accountRef.accountId}/${poolId}`, classified, error); + return of(this._emptyNodeCounts()); + }), + ); + } + + public classifyError(error: any): WorkbenchClassifiedError { + const status = Number(error && error.status); + const lowerCode = ((error && (error.code || (error.body && error.body.code))) || "").toString().toLowerCase(); + const lowerMessage = ((error && error.message) || "").toString().toLowerCase(); + + if (lowerCode.includes("quota") || lowerMessage.includes("quota")) { + return { category: "quota", message: error && error.message || "Quota error", status: status || undefined }; + } + + if (status === 409 || status === 429 || status === HttpCode.RequestTimeout || status >= 500) { + return { category: "transient", message: error && error.message || "Transient error", status: status || undefined }; + } + + return { category: "fatal", message: error && error.message || "Fatal error", status: status || undefined }; + } + + private _listPoolsForAccount(account: BatchAccount): Observable { + return this._listPoolSummaries(account).pipe( + switchMap((poolSummaries) => { + return this._getAllPoolNodeCounts(account).pipe( + switchMap((countsByPool) => { + return this._getQuotaStatus(account).pipe( + map((quota) => { + return poolSummaries.map((pool) => { + const nodeCounts = countsByPool.get(pool.id) || this._emptyNodeCounts(); + return { + subscriptionId: account instanceof ArmBatchAccount ? account.subscriptionId : null, + accountId: account.id, + accountName: account.displayName || account.name, + location: account instanceof ArmBatchAccount ? account.location : null, + poolId: pool.id, + allocationState: pool.allocationState || null, + nodeCountsByState: nodeCounts, + alerts: this._buildAlerts(pool, nodeCounts, quota), + }; + }); + }), + ); + }), + ); + }), + ); + } + + private _listPoolSummaries(account: BatchAccount): Observable { + const params = new HttpParams() + .set("$select", "id,allocationState") + .set("maxresults", "200"); + + return this.batchHttp.requestForAccount( + account, + "GET", + "/pools", + { params }, + ).pipe( + expand((response: BatchListResponse) => { + return response && response["odata.nextLink"] + ? this.batchHttp.requestForAccount(account, "GET", response["odata.nextLink"]) + : of(null); + }), + reduce((allPools: BatchPoolSummary[], response: BatchListResponse) => { + if (!response || !Array.isArray(response.value)) { + return allPools; + } + return [...allPools, ...response.value]; + }, []), + ); + } + + private _getAllPoolNodeCounts(account: BatchAccount): Observable> { + return this.batchHttp.requestForAccount( + account, + "GET", + "/nodecounts", + { params: new HttpParams().set("maxresults", "200") }, + ).pipe( + expand((response) => { + return response && response["odata.nextLink"] + ? this.batchHttp.requestForAccount(account, "GET", response["odata.nextLink"]) + : of(null); + }), + reduce((allItems: BatchPoolNodeCounts[], response: BatchListResponse) => { + if (!response || !Array.isArray(response.value)) { + return allItems; + } + return [...allItems, ...response.value]; + }, []), + map((items) => { + const countsByPool = new Map(); + for (const item of items) { + countsByPool.set(item.poolId, this._mergeDedicatedAndLowPriorityCounts(item)); + } + return countsByPool; + }), + catchError((error) => { + const classified = this.classifyError(error); + log.error(`[WorkbenchDiscovery] getAllPoolNodeCounts failed for ${account.id}`, classified, error); + return of(new Map()); + }), + ); + } + + private _loadAccounts(): Observable { + return this.subscriptionService.load().pipe( + switchMap(() => this.subscriptionService.subscriptions.pipe(take(1))), + switchMap((subscriptions) => { + return from(subscriptions.toArray()).pipe( + concatMap((subscription) => { + return this.armBatchAccountService.list(subscription.subscriptionId).pipe( + map((accounts) => accounts.toArray()), + catchError((error) => { + const classified = this.classifyError(error); + log.error(`[WorkbenchDiscovery] list ARM accounts failed for ${subscription.subscriptionId}`, + classified, error); + return of([]); + }), + ); + }), + reduce((allAccounts, accounts) => { + return [...allAccounts, ...accounts]; + }, [] as ArmBatchAccount[]), + ); + }), + switchMap((armAccounts) => { + return this.localBatchAccountService.load().pipe( + switchMap(() => this.localBatchAccountService.accounts.pipe(take(1))), + map((localAccounts) => { + return [...armAccounts, ...localAccounts.toArray()]; + }), + catchError((error) => { + const classified = this.classifyError(error); + log.error("[WorkbenchDiscovery] loading local accounts failed", classified, error); + return of(armAccounts); + }), + ); + }), + catchError((error) => { + const classified = this.classifyError(error); + log.error("[WorkbenchDiscovery] loading accounts failed", classified, error); + return of([]); + }), + ); + } + + private _resolveAccount(accountRef: WorkbenchAccountRef): Observable { + return this.batchAccountService.get(accountRef.accountId).pipe( + map((account) => { + if (!account) { + throw new Error(`Batch account ${accountRef.accountId} not found`); + } + return account; + }), + ); + } + + private _getQuotaStatus(account: BatchAccount): Observable { + if (!(account instanceof ArmBatchAccount) || !account.subscription || !account.location) { + return of(null); + } + return this.armBatchAccountService.accountQuota(account.subscription, account.location).pipe( + map((quota) => { + if (!quota) { + return null; + } + return { + used: quota.used, + quota: quota.quota, + }; + }), + catchError((error) => { + const classified = this.classifyError(error); + log.warn(`[WorkbenchDiscovery] quota lookup failed for ${account.id}`, classified); + return of(null); + }), + ); + } + + private _toAccountRef(account: BatchAccount): WorkbenchAccountRef { + return { + subscriptionId: account instanceof ArmBatchAccount ? account.subscriptionId : null, + accountId: account.id, + accountName: account.displayName || account.name, + location: account instanceof ArmBatchAccount ? account.location : null, + endpoint: account.url, + }; + } + + private _mergeDedicatedAndLowPriorityCounts(item: BatchPoolNodeCounts): WorkbenchNodeCountsByState { + const dedicated = item && item.dedicated || this._emptyNodeCounts(); + const lowPriority = item && item.lowPriority || this._emptyNodeCounts(); + return { + creating: dedicated.creating + lowPriority.creating, + idle: dedicated.idle + lowPriority.idle, + leavingPool: dedicated.leavingPool + lowPriority.leavingPool, + offline: dedicated.offline + lowPriority.offline, + preempted: dedicated.preempted + lowPriority.preempted, + rebooting: dedicated.rebooting + lowPriority.rebooting, + reimaging: dedicated.reimaging + lowPriority.reimaging, + running: dedicated.running + lowPriority.running, + startTaskFailed: dedicated.startTaskFailed + lowPriority.startTaskFailed, + starting: dedicated.starting + lowPriority.starting, + total: dedicated.total + lowPriority.total, + unknown: dedicated.unknown + lowPriority.unknown, + unusable: dedicated.unusable + lowPriority.unusable, + waitingForStartTask: dedicated.waitingForStartTask + lowPriority.waitingForStartTask, + }; + } + + private _buildAlerts( + pool: BatchPoolSummary, + nodeCounts: WorkbenchNodeCountsByState, + quota: QuotaStatus | null): string[] { + const alerts: string[] = []; + + if (pool.allocationState === PoolAllocationState.resizing || pool.allocationState === PoolAllocationState.stopping) { + alerts.push("Pool allocation is not steady"); + } + if (nodeCounts.startTaskFailed > 0) { + alerts.push("Nodes with startTaskFailed state"); + } + if (nodeCounts.unusable > 0 || nodeCounts.unknown > 0) { + alerts.push("Nodes with unusable or unknown state"); + } + if (quota && quota.quota >= 0 && quota.used >= quota.quota) { + alerts.push("Batch account quota reached"); + } + return alerts; + } + + private _emptyNodeCounts(): WorkbenchNodeCountsByState { + return { + creating: 0, + idle: 0, + leavingPool: 0, + offline: 0, + preempted: 0, + rebooting: 0, + reimaging: 0, + running: 0, + startTaskFailed: 0, + starting: 0, + total: 0, + unknown: 0, + unusable: 0, + waitingForStartTask: 0, + }; + } +} diff --git a/desktop/src/app/services/workbench/workbench-types.ts b/desktop/src/app/services/workbench/workbench-types.ts new file mode 100644 index 0000000000..abbe5f31de --- /dev/null +++ b/desktop/src/app/services/workbench/workbench-types.ts @@ -0,0 +1,40 @@ +export interface WorkbenchAccountRef { + subscriptionId: string; + accountId: string; + accountName: string; + location: string; + endpoint: string; +} + +export interface WorkbenchPoolRow { + subscriptionId: string; + accountId: string; + accountName: string; + location: string; + poolId: string; + allocationState: string; + nodeCountsByState: { + [state: string]: number; + }; + alerts: string[]; +} + +export interface WorkbenchNodeRow { + nodeId: string; + state: string; + stateTransitionTime?: Date | string; + errors?: string[]; +} + +export type ActionStopReason = "none" | "quota" | "transient" | "fatal" | "conflict" | "throttled" | "timeout"; +export type ActionStatus = "pending" | "running" | "succeeded" | "failed" | "skipped"; + +export interface ActionResult { + scope: string; + status: ActionStatus; + stopReason?: ActionStopReason; + errors?: string[]; + startedAt: Date | string; + finishedAt: Date | string; + retries?: number; +} From c2ffeb45ae03b0341c631ef484d7370e7b5db4cc Mon Sep 17 00:00:00 2001 From: max313iq Date: Fri, 20 Mar 2026 01:48:33 +0300 Subject: [PATCH 4/7] Integrate pool workbench detail and start task components --- .../src/app/components/pool/pool.module.ts | 8 +- desktop/src/app/components/workbench/index.ts | 2 + .../pool-control-workbench.component.ts | 464 ++++++++++++++---- .../workbench/pool-control-workbench.html | 59 ++- .../workbench/pool-control-workbench.scss | 21 + .../workbench/workbench-discovery.service.ts | 38 +- 6 files changed, 458 insertions(+), 134 deletions(-) diff --git a/desktop/src/app/components/pool/pool.module.ts b/desktop/src/app/components/pool/pool.module.ts index c074846f44..ecdc265f31 100644 --- a/desktop/src/app/components/pool/pool.module.ts +++ b/desktop/src/app/components/pool/pool.module.ts @@ -19,7 +19,11 @@ import { PoolGraphsModule } from "app/components/pool/graphs"; import { PoolHomeComponent } from "app/components/pool/home/pool-home.component"; import { StartTaskModule } from "app/components/pool/start-task"; import { TaskBaseModule } from "app/components/task/base"; -import { PoolControlWorkbenchComponent } from "app/components/workbench"; +import { + PoolControlWorkbenchComponent, + PoolDetailPanelComponent, + StartTaskEditorComponent, +} from "app/components/workbench"; import { EditPoolModule } from "./action/edit/edit-pool.module"; import { NetworkConfigurationModule } from "./network-configuration"; import { PoolRoutingModule } from "./pool-routing.module"; @@ -32,8 +36,10 @@ const components = [ PoolCreateBasicDialogComponent, PoolHomeComponent, PoolControlWorkbenchComponent, + PoolDetailPanelComponent, PoolListComponent, PoolScalePickerComponent, + StartTaskEditorComponent, VmSizePickerComponent, VmSizePickerFilterComponent, PoolResizeDialogComponent, diff --git a/desktop/src/app/components/workbench/index.ts b/desktop/src/app/components/workbench/index.ts index ad3369fc7d..7114809790 100644 --- a/desktop/src/app/components/workbench/index.ts +++ b/desktop/src/app/components/workbench/index.ts @@ -1 +1,3 @@ export * from "./pool-control-workbench.component"; +export * from "./pool-detail-panel.component"; +export * from "./start-task-editor.component"; diff --git a/desktop/src/app/components/workbench/pool-control-workbench.component.ts b/desktop/src/app/components/workbench/pool-control-workbench.component.ts index 4dafc1d96f..070e62b1ea 100644 --- a/desktop/src/app/components/workbench/pool-control-workbench.component.ts +++ b/desktop/src/app/components/workbench/pool-control-workbench.component.ts @@ -1,31 +1,25 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, OnDestroy, OnInit, Optional } from "@angular/core"; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core"; import { UserConfigurationService } from "@batch-flask/core"; import { ListSelection } from "@batch-flask/core/list"; import { TableConfig } from "@batch-flask/ui"; +import { BatchAccount } from "app/models"; +import { BatchAccountService } from "app/services/batch-account"; +import { + BatchPoolActionsService, + StartTaskApplyProgress, + StartTaskApplyRequest, + StartTaskApplyService, + StartTaskApplyTarget, + WorkbenchDiscoveryService, + WorkbenchPoolRow, +} from "app/services/workbench"; import { BEUserConfiguration } from "common"; -import { Observable, Subject, Subscription, firstValueFrom, timer } from "rxjs"; +import { Subject, Subscription, timer } from "rxjs"; import { takeUntil } from "rxjs/operators"; +import { PoolDetailSummary } from "./pool-detail-panel.component"; -interface WorkbenchPoolRow { - uid: string; - subscriptionId: string; - accountName: string; - location: string; - poolId: string; - allocationState: string; - nodeCountsByState: { - idle: number; - running: number; - starting: number; - startTaskFailed: number; - unusable: number; - }; - alerts: string[]; -} - -interface DiscoveryServiceLike { - listAccounts: () => Observable | Promise | any; - listPools: (account: any) => Observable | Promise | any; +interface WorkbenchPoolTableRow extends WorkbenchPoolRow { + id: string; } @Component({ @@ -55,12 +49,15 @@ export class PoolControlWorkbenchComponent implements OnInit, OnDestroy { public isRefreshing = false; public refreshError: string | null = null; public statusMessage = "No data loaded."; + public actionMessage: string | null = null; + public actionError: string | null = null; + public bulkSummary: string | null = null; + public exportedJson: string | null = null; - public activeItem: WorkbenchPoolRow | null = null; public selection = new ListSelection(); - - public allRows: WorkbenchPoolRow[] = []; - public displayedRows: WorkbenchPoolRow[] = []; + public allRows: WorkbenchPoolTableRow[] = []; + public displayedRows: WorkbenchPoolTableRow[] = []; + public selectedAccount: BatchAccount | null = null; public subscriptionFilter = ""; public accountFilter = ""; @@ -75,16 +72,37 @@ export class PoolControlWorkbenchComponent implements OnInit, OnDestroy { public autoRefreshEnabled = false; public autoRefreshIntervalSeconds = 30; + public startTaskPreviewText: string | null = null; + public startTaskProgress: StartTaskApplyProgress | null = null; + public startTaskError: string | null = null; + public startTaskRunning = false; + private _autoRefreshSub: Subscription | null = null; + private _startTaskSub: Subscription | null = null; private _destroy = new Subject(); + private _busyRowIds = new Set(); + private _accountCache = new Map(); + private _activeItem: WorkbenchPoolTableRow | null = null; constructor( private settingsService: UserConfigurationService, + private discoveryService: WorkbenchDiscoveryService, + private batchAccountService: BatchAccountService, + private poolActionsService: BatchPoolActionsService, + private startTaskApplyService: StartTaskApplyService, private changeDetector: ChangeDetectorRef, - @Optional() @Inject("WorkbenchDiscoveryService") private discoveryService: DiscoveryServiceLike | null, ) { } + public get activeItem(): WorkbenchPoolTableRow | null { + return this._activeItem; + } + + public set activeItem(value: WorkbenchPoolTableRow | null) { + this._activeItem = value; + void this._loadSelectedAccount(); + } + public ngOnInit() { this.settingsService.watch("features").pipe(takeUntil(this._destroy)).subscribe((features: any) => { this.isFeatureEnabled = Boolean(features && features.poolControlWorkbench); @@ -109,12 +127,49 @@ export class PoolControlWorkbenchComponent implements OnInit, OnDestroy { this._autoRefreshSub.unsubscribe(); this._autoRefreshSub = null; } + if (this._startTaskSub) { + this._startTaskSub.unsubscribe(); + this._startTaskSub = null; + } this._destroy.next(); this._destroy.complete(); } public get selectedCount(): number { - return this.selection.all ? this.displayedRows.length : this.selection.keys.size; + return this.selectedRows.length; + } + + public get selectedRows(): WorkbenchPoolTableRow[] { + if (this.selection.all) { + return this.displayedRows; + } + return this.displayedRows.filter((row) => this.selection.keys.has(row.id)); + } + + public get currentTarget(): StartTaskApplyTarget | null { + return this.activeItem ? this._toApplyTarget(this.activeItem) : null; + } + + public get selectedTargets(): StartTaskApplyTarget[] { + return this.selectedRows.map((row) => this._toApplyTarget(row)); + } + + public get allTargets(): StartTaskApplyTarget[] { + return this.displayedRows.map((row) => this._toApplyTarget(row)); + } + + public get selectedSummary(): PoolDetailSummary | null { + const row = this.activeItem; + if (!row) { + return null; + } + return { + accountName: row.accountName, + location: row.location, + allocationState: row.allocationState, + nodeCountsByState: row.nodeCountsByState, + alerts: row.alerts, + }; } public onAutoRefreshChanged() { @@ -125,6 +180,10 @@ export class PoolControlWorkbenchComponent implements OnInit, OnDestroy { this.selection.clear(); } + public isRowBusy(row: WorkbenchPoolTableRow): boolean { + return this._busyRowIds.has(row.id); + } + public applyFilters() { const search = this.searchFilter.trim().toLowerCase(); this.displayedRows = this.allRows.filter((row) => { @@ -140,13 +199,11 @@ export class PoolControlWorkbenchComponent implements OnInit, OnDestroy { if (this.onlyAlerts && row.alerts.length === 0) { return false; } - if (search) { - const haystack = `${row.poolId} ${row.accountName} ${row.location} ${row.subscriptionId}`.toLowerCase(); - if (!haystack.includes(search)) { - return false; - } + if (!search) { + return true; } - return true; + const haystack = `${row.poolId} ${row.accountName} ${row.location} ${row.subscriptionId}`.toLowerCase(); + return haystack.includes(search); }); this.statusMessage = this.displayedRows.length === 0 ? "No pools match current filters." : ""; } @@ -162,35 +219,22 @@ export class PoolControlWorkbenchComponent implements OnInit, OnDestroy { this.changeDetector.markForCheck(); try { - if (!this.discoveryService - || typeof this.discoveryService.listAccounts !== "function" - || typeof this.discoveryService.listPools !== "function") { - this.allRows = []; - this.displayedRows = []; - this.statusMessage = "Discovery service is unavailable. Waiting for data layer integration."; - return; - } - - const accounts = await this._toPromise(this.discoveryService.listAccounts()); - const rows: WorkbenchPoolRow[] = []; - const accountList = Array.isArray(accounts) ? accounts : []; - - for (const account of accountList) { - const pools = await this._toPromise(this.discoveryService.listPools(account)); - const poolList = Array.isArray(pools) ? pools : []; - for (const pool of poolList) { - rows.push(this._mapPoolRow(account, pool)); + const accounts = await this.discoveryService.listAccounts().toPromise(); + const rows: WorkbenchPoolTableRow[] = []; + for (const account of accounts || []) { + const pools = await this.discoveryService.listPools(account).toPromise(); + for (const pool of pools || []) { + rows.push(this._normalizeRow(pool)); } } this.allRows = rows; this._rebuildFilterOptions(); this.applyFilters(); - if (rows.length > 0) { - this.statusMessage = ""; - } else if (!this.statusMessage) { - this.statusMessage = "No pools were discovered."; - } + this.statusMessage = rows.length === 0 ? "No pools were discovered." : ""; + this.selection.clear(); + this.bulkSummary = null; + this.exportedJson = null; } catch (error) { this.refreshError = this._describeError(error); this.statusMessage = "Refresh failed."; @@ -200,6 +244,184 @@ export class PoolControlWorkbenchComponent implements OnInit, OnDestroy { } } + public async resizeRow(row: WorkbenchPoolTableRow) { + const targetInput = window.prompt(`Target dedicated nodes for ${row.poolId}`, "0"); + if (targetInput == null) { + return; + } + const target = Number(targetInput); + if (!Number.isFinite(target) || target < 0) { + this.actionError = "Invalid target node count."; + return; + } + try { + await this._runRowAction(row, `Resizing ${row.poolId}`, async (account) => { + await this.poolActionsService.resizePool(account, row.poolId, Math.floor(target)).toPromise(); + }); + } catch { + return; + } + } + + public async stopResizeRow(row: WorkbenchPoolTableRow) { + try { + await this._runRowAction(row, `Stopping resize for ${row.poolId}`, async (account) => { + await this.poolActionsService.stopResize(account, row.poolId).toPromise(); + }); + } catch { + return; + } + } + + public async deleteRow(row: WorkbenchPoolTableRow) { + if (!window.confirm(`Delete pool ${row.poolId}? This action is destructive.`)) { + return; + } + try { + await this._runRowAction(row, `Deleting ${row.poolId}`, async (account) => { + await this.poolActionsService.deletePool(account, row.poolId).toPromise(); + }); + } catch { + return; + } + await this.refresh(); + } + + public async exportRow(row: WorkbenchPoolTableRow) { + try { + await this._runRowAction(row, `Exporting ${row.poolId}`, async (account) => { + const result = await this.poolActionsService.exportPoolJson(account, row.poolId).toPromise(); + this.exportedJson = JSON.stringify(result, null, 2); + }); + } catch { + return; + } + } + + public async bulkResizeSelected() { + if (this.selectedRows.length === 0) { + return; + } + const targetInput = window.prompt("Target dedicated nodes for selected pools", "0"); + if (targetInput == null) { + return; + } + const target = Number(targetInput); + if (!Number.isFinite(target) || target < 0) { + this.actionError = "Invalid target node count."; + return; + } + + let succeeded = 0; + let failed = 0; + for (const row of this.selectedRows) { + try { + await this._runRowAction(row, `Resizing ${row.poolId}`, async (account) => { + await this.poolActionsService.resizePool(account, row.poolId, Math.floor(target)).toPromise(); + }); + succeeded++; + } catch { + failed++; + } + } + this.bulkSummary = `Bulk resize complete: ${succeeded} succeeded, ${failed} failed.`; + this.changeDetector.markForCheck(); + } + + public async cleanupBootstrapPools() { + const prefix = window.prompt("Delete pools with ID prefix", "bootstrap"); + if (!prefix) { + return; + } + const targets = this.displayedRows.filter((row) => row.poolId.startsWith(prefix)); + if (targets.length === 0) { + this.bulkSummary = `No pools found with prefix '${prefix}'.`; + return; + } + if (!window.confirm(`Delete ${targets.length} pool(s) with prefix '${prefix}'?`)) { + return; + } + + let succeeded = 0; + let failed = 0; + for (const row of targets) { + try { + await this._runRowAction(row, `Deleting ${row.poolId}`, async (account) => { + await this.poolActionsService.deletePool(account, row.poolId).toPromise(); + }); + succeeded++; + } catch { + failed++; + } + } + + this.bulkSummary = `Cleanup complete: ${succeeded} deleted, ${failed} failed.`; + await this.refresh(); + } + + public async exportSelectedAsJson() { + if (this.selectedRows.length === 0) { + return; + } + + const bundle: any[] = []; + for (const row of this.selectedRows) { + try { + const account = await this._resolveAccount(row.accountId); + const payload = await this.poolActionsService.exportPoolJson(account, row.poolId).toPromise(); + bundle.push({ + subscriptionId: row.subscriptionId, + accountName: row.accountName, + poolId: row.poolId, + payload, + }); + } catch (error) { + bundle.push({ + subscriptionId: row.subscriptionId, + accountName: row.accountName, + poolId: row.poolId, + error: this._describeError(error), + }); + } + } + this.exportedJson = JSON.stringify(bundle, null, 2); + } + + public handleStartTaskPreview(request: StartTaskApplyRequest) { + const preview = this.startTaskApplyService.preview(request); + if (preview.validationErrors.length > 0) { + this.startTaskPreviewText = `Invalid request:\n${preview.validationErrors.join("\n")}`; + return; + } + this.startTaskPreviewText = `Scope: ${preview.scope}\nPools: ${preview.totalTargets}`; + } + + public handleStartTaskApply(request: StartTaskApplyRequest) { + if (this._startTaskSub) { + this._startTaskSub.unsubscribe(); + this._startTaskSub = null; + } + + this.startTaskError = null; + this.startTaskRunning = true; + this.startTaskProgress = null; + this._startTaskSub = this.startTaskApplyService.applyStartTask(request).subscribe({ + next: (progress) => { + this.startTaskProgress = progress; + this.changeDetector.markForCheck(); + }, + error: (error) => { + this.startTaskRunning = false; + this.startTaskError = this._describeError(error); + this.changeDetector.markForCheck(); + }, + complete: () => { + this.startTaskRunning = false; + this.changeDetector.markForCheck(); + }, + }); + } + private _configureAutoRefresh() { if (this._autoRefreshSub) { this._autoRefreshSub.unsubscribe(); @@ -223,39 +445,39 @@ export class PoolControlWorkbenchComponent implements OnInit, OnDestroy { } private _rebuildFilterOptions() { - this.subscriptionOptions = this._sortedUnique(this.allRows.map(x => x.subscriptionId)); - this.accountOptions = this._sortedUnique(this.allRows.map(x => x.accountName)); - this.regionOptions = this._sortedUnique(this.allRows.map(x => x.location)); + this.subscriptionOptions = this._sortedUnique(this.allRows.map((x) => x.subscriptionId)); + this.accountOptions = this._sortedUnique(this.allRows.map((x) => x.accountName)); + this.regionOptions = this._sortedUnique(this.allRows.map((x) => x.location)); } private _sortedUnique(values: string[]): string[] { - return [...new Set(values.filter(x => Boolean(x)))].sort((a, b) => a.localeCompare(b)); + return [...new Set(values.filter((x) => Boolean(x)))].sort((a, b) => a.localeCompare(b)); } - private _mapPoolRow(account: any, pool: any): WorkbenchPoolRow { - const subscriptionId = this._pick(account, ["subscriptionId", "subscription.id", "subscription.subscriptionId"], "unknown-subscription"); - const accountName = this._pick(account, ["accountName", "displayName", "name"], "unknown-account"); - const location = this._pick(account, ["location", "region"], "unknown-region"); - const poolId = this._pick(pool, ["poolId", "id"], "unknown-pool"); - const allocationState = this._pick(pool, ["allocationState"], "unknown"); - const nodeCounts = pool && pool.nodeCountsByState ? pool.nodeCountsByState : {}; - const alerts = Array.isArray(pool && pool.alerts) ? pool.alerts : []; - + private _normalizeRow(row: WorkbenchPoolRow): WorkbenchPoolTableRow { + const subscriptionId = row.subscriptionId || "unknown-subscription"; + const accountName = row.accountName || "unknown-account"; + const location = row.location || "unknown-region"; + const poolId = row.poolId || "unknown-pool"; + const accountId = row.accountId || "unknown-account-id"; return { - uid: `${subscriptionId}|${accountName}|${poolId}`, + ...row, + id: `${accountId}|${poolId}`, subscriptionId, + accountId, accountName, location, poolId, - allocationState, + allocationState: row.allocationState || "unknown", nodeCountsByState: { - idle: this._asNumber(nodeCounts.idle), - running: this._asNumber(nodeCounts.running), - starting: this._asNumber(nodeCounts.starting), - startTaskFailed: this._asNumber(nodeCounts.startTaskFailed), - unusable: this._asNumber(nodeCounts.unusable), + ...row.nodeCountsByState, + idle: this._asNumber(row.nodeCountsByState && row.nodeCountsByState.idle), + running: this._asNumber(row.nodeCountsByState && row.nodeCountsByState.running), + starting: this._asNumber(row.nodeCountsByState && row.nodeCountsByState.starting), + startTaskFailed: this._asNumber(row.nodeCountsByState && row.nodeCountsByState.startTaskFailed), + unusable: this._asNumber(row.nodeCountsByState && row.nodeCountsByState.unusable), }, - alerts, + alerts: Array.isArray(row.alerts) ? row.alerts : [], }; } @@ -264,40 +486,76 @@ export class PoolControlWorkbenchComponent implements OnInit, OnDestroy { return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0; } - private _pick(source: any, paths: string[], fallback: string): string { - for (const path of paths) { - let current = source; - const segments = path.split("."); - for (const segment of segments) { - current = current && current[segment]; - } - if (typeof current === "string" && current.trim().length > 0) { - return current; - } + private _toApplyTarget(row: WorkbenchPoolTableRow): StartTaskApplyTarget { + return { + subscriptionId: row.subscriptionId, + accountId: row.accountId, + accountName: row.accountName, + location: row.location, + poolId: row.poolId, + }; + } + + private async _loadSelectedAccount() { + if (!this.activeItem) { + this.selectedAccount = null; + this.changeDetector.markForCheck(); + return; + } + + try { + this.selectedAccount = await this._resolveAccount(this.activeItem.accountId); + this.actionError = null; + } catch (error) { + this.selectedAccount = null; + this.actionError = this._describeError(error); } - return fallback; + this.changeDetector.markForCheck(); } - private _describeError(error: any): string { - if (!error) { - return "Unknown refresh error"; + private async _runRowAction( + row: WorkbenchPoolTableRow, + message: string, + callback: (account: BatchAccount) => Promise, + ): Promise { + this._busyRowIds.add(row.id); + this.actionMessage = message; + this.actionError = null; + this.changeDetector.markForCheck(); + + try { + const account = await this._resolveAccount(row.accountId); + await callback(account); + } catch (error) { + this.actionError = this._describeError(error); + throw error; + } finally { + this._busyRowIds.delete(row.id); + this.actionMessage = null; + this.changeDetector.markForCheck(); } - if (typeof error === "string") { - return error; + } + + private async _resolveAccount(accountId: string): Promise { + if (this._accountCache.has(accountId)) { + return this._accountCache.get(accountId); } - if (error.message) { - return error.message; + + const account = await this.batchAccountService.get(accountId).toPromise(); + if (!account) { + throw new Error(`Batch account '${accountId}' not found.`); } - return "Unknown refresh error"; + this._accountCache.set(accountId, account); + return account; } - private _toPromise(value: T | Promise | Observable): Promise { - if (value && typeof (value as any).subscribe === "function") { - return firstValueFrom(value as Observable); + private _describeError(error: any): string { + if (!error) { + return "Unknown error."; } - if (value && typeof (value as any).then === "function") { - return value as Promise; + if (typeof error === "string") { + return error; } - return Promise.resolve(value as T); + return error.message || error.statusText || error.code || "Unknown error."; } } diff --git a/desktop/src/app/components/workbench/pool-control-workbench.html b/desktop/src/app/components/workbench/pool-control-workbench.html index aa52c31ad2..fe9ec54559 100644 --- a/desktop/src/app/components/workbench/pool-control-workbench.html +++ b/desktop/src/app/components/workbench/pool-control-workbench.html @@ -65,7 +65,15 @@

Pool Control Workbench

{{selectedCount}} selected Clear selection - Bulk actions are placeholders in this slice. + + +
Pool Control Workbench
Actions
- - Actions - + + + +
-
{{statusMessage}}
-
{{statusMessage}}
+
{{statusMessage}}
+
{{actionMessage}}
+
{{bulkSummary}}
{{refreshError}}
+
{{actionError}}
+
+

Export JSON

+
{{exportedJson}}
+
+ +
+

Start task rollout

+ + + +
{{startTaskPreviewText}}
+
+ Applying start task: {{startTaskProgress.completedTargets}} / {{startTaskProgress.totalTargets}} +
+
{{startTaskError}}
+
+ Start task summary: + {{startTaskProgress.summary.succeeded}} succeeded, + {{startTaskProgress.summary.failed}} failed, + {{startTaskProgress.summary.skipped}} skipped. +
+
+ +
+

Pool detail

+ + +
diff --git a/desktop/src/app/components/workbench/pool-control-workbench.scss b/desktop/src/app/components/workbench/pool-control-workbench.scss index 5ed14706ed..55c49df98d 100644 --- a/desktop/src/app/components/workbench/pool-control-workbench.scss +++ b/desktop/src/app/components/workbench/pool-control-workbench.scss @@ -78,6 +78,7 @@ .selection-bar { align-items: center; display: flex; + flex-wrap: wrap; gap: 10px; } @@ -95,4 +96,24 @@ color: #b00020; font-size: 12px; } + + .start-task-section, + .detail-section, + .export-json { + border-top: 1px solid #e5e5e5; + margin-top: 12px; + padding-top: 12px; + } + + .start-task-preview, + .export-json pre { + background: #fafafa; + border: 1px solid #d9d9d9; + border-radius: 4px; + margin: 8px 0 0; + max-height: 280px; + overflow: auto; + padding: 10px; + white-space: pre-wrap; + } } diff --git a/desktop/src/app/services/workbench/workbench-discovery.service.ts b/desktop/src/app/services/workbench/workbench-discovery.service.ts index 7a01f5726b..06f1a2f359 100644 --- a/desktop/src/app/services/workbench/workbench-discovery.service.ts +++ b/desktop/src/app/services/workbench/workbench-discovery.service.ts @@ -8,17 +8,10 @@ import { ArmBatchAccountService, BatchAccountService, LocalBatchAccountService } import { SubscriptionService } from "app/services/subscription"; import { from, Observable, of } from "rxjs"; import { catchError, concatMap, expand, map, reduce, switchMap, take } from "rxjs/operators"; +import { WorkbenchAccountRef, WorkbenchPoolRow } from "./workbench-types"; export type WorkbenchErrorClass = "quota" | "transient" | "fatal"; -export interface WorkbenchAccountRef { - subscriptionId: string | null; - accountId: string; - accountName: string; - location: string | null; - endpoint: string; -} - export interface WorkbenchNodeCountsByState { creating: number; idle: number; @@ -36,17 +29,6 @@ export interface WorkbenchNodeCountsByState { waitingForStartTask: number; } -export interface WorkbenchPoolRow { - subscriptionId: string | null; - accountId: string; - accountName: string; - location: string | null; - poolId: string; - allocationState: string | null; - nodeCountsByState: WorkbenchNodeCountsByState; - alerts: string[]; -} - export interface WorkbenchClassifiedError { category: WorkbenchErrorClass; message: string; @@ -140,12 +122,16 @@ export class WorkbenchDiscoveryService { return poolSummaries.map((pool) => { const nodeCounts = countsByPool.get(pool.id) || this._emptyNodeCounts(); return { - subscriptionId: account instanceof ArmBatchAccount ? account.subscriptionId : null, + subscriptionId: account instanceof ArmBatchAccount + ? account.subscriptionId || "unknown-subscription" + : "local", accountId: account.id, accountName: account.displayName || account.name, - location: account instanceof ArmBatchAccount ? account.location : null, + location: account instanceof ArmBatchAccount + ? account.location || "unknown-region" + : "local", poolId: pool.id, - allocationState: pool.allocationState || null, + allocationState: pool.allocationState || "unknown", nodeCountsByState: nodeCounts, alerts: this._buildAlerts(pool, nodeCounts, quota), }; @@ -293,10 +279,14 @@ export class WorkbenchDiscoveryService { private _toAccountRef(account: BatchAccount): WorkbenchAccountRef { return { - subscriptionId: account instanceof ArmBatchAccount ? account.subscriptionId : null, + subscriptionId: account instanceof ArmBatchAccount + ? account.subscriptionId || "unknown-subscription" + : "local", accountId: account.id, accountName: account.displayName || account.name, - location: account instanceof ArmBatchAccount ? account.location : null, + location: account instanceof ArmBatchAccount + ? account.location || "unknown-region" + : "local", endpoint: account.url, }; } From 9e43789ff0320b121c7f812bd996ebd0b8291398 Mon Sep 17 00:00:00 2001 From: max313iq Date: Fri, 20 Mar 2026 01:51:56 +0300 Subject: [PATCH 5/7] Fix workbench action bindings and strengthen start task validation --- .../components/workbench/pool-control-workbench.html | 11 ++++++----- .../workbench/pool-detail-panel.component.ts | 2 +- .../services/workbench/start-task-apply.service.ts | 2 +- .../services/workbench/workbench-discovery.service.ts | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/desktop/src/app/components/workbench/pool-control-workbench.html b/desktop/src/app/components/workbench/pool-control-workbench.html index fe9ec54559..ef71b1b76d 100644 --- a/desktop/src/app/components/workbench/pool-control-workbench.html +++ b/desktop/src/app/components/workbench/pool-control-workbench.html @@ -5,14 +5,13 @@

Pool Control Workbench

Cross-account pool visibility with safe refresh controls.

- Refresh - +