release orchestration strengthening

This commit is contained in:
master
2026-01-17 21:32:03 +02:00
parent 195dff2457
commit da27b9faa9
256 changed files with 94634 additions and 2269 deletions

View File

@@ -0,0 +1,404 @@
// -----------------------------------------------------------------------------
// workflow-visualizer.visual.spec.ts
// Sprint: SPRINT_20260117_032_ReleaseOrchestrator_workflow_visualization
// Task: TASK-032-12 - Visual Regression Tests for DAG Visualization
// Description: Playwright visual regression tests for workflow visualization
// -----------------------------------------------------------------------------
import { test, expect } from '@playwright/test';
test.describe('Workflow DAG Visualization', () => {
test.beforeEach(async ({ page }) => {
// Navigate to test workflow page
await page.goto('/workflows/test-run-001');
await page.waitForSelector('.workflow-visualizer');
});
test.describe('Node Rendering', () => {
test('renders nodes at various complexities', async ({ page }) => {
// Test with 10 nodes
await page.goto('/workflows/test-10-nodes');
await page.waitForSelector('.node', { timeout: 5000 });
await expect(page.locator('.node')).toHaveCount(10);
await expect(page.locator('.dag-canvas')).toHaveScreenshot('dag-10-nodes.png');
// Test with 50 nodes
await page.goto('/workflows/test-50-nodes');
await page.waitForSelector('.node', { timeout: 10000 });
await expect(page.locator('.node')).toHaveCount(50);
await expect(page.locator('.dag-canvas')).toHaveScreenshot('dag-50-nodes.png');
});
test('renders large workflow (100+ nodes) with acceptable performance', async ({ page }) => {
const startTime = Date.now();
await page.goto('/workflows/test-100-nodes');
await page.waitForSelector('.node', { timeout: 15000 });
const loadTime = Date.now() - startTime;
expect(loadTime).toBeLessThan(10000); // Should load within 10 seconds
await expect(page.locator('.node')).toHaveCount(100);
await expect(page.locator('.dag-canvas')).toHaveScreenshot('dag-100-nodes.png', {
maxDiffPixelRatio: 0.05 // Allow 5% variance for large graphs
});
});
});
test.describe('Node Status States', () => {
test('displays pending node correctly', async ({ page }) => {
await page.goto('/workflows/test-pending');
const pendingNode = page.locator('.node-pending').first();
await expect(pendingNode).toBeVisible();
await expect(pendingNode).toHaveScreenshot('node-pending.png');
});
test('displays running node with animation', async ({ page }) => {
await page.goto('/workflows/test-running');
const runningNode = page.locator('.node-running').first();
await expect(runningNode).toBeVisible();
// Check for pulse animation
const statusIndicator = runningNode.locator('.pulse');
await expect(statusIndicator).toBeVisible();
// Capture animation frame
await expect(runningNode).toHaveScreenshot('node-running.png');
});
test('displays succeeded node correctly', async ({ page }) => {
await page.goto('/workflows/test-succeeded');
const succeededNode = page.locator('.node-succeeded').first();
await expect(succeededNode).toBeVisible();
await expect(succeededNode).toHaveScreenshot('node-succeeded.png');
});
test('displays failed node correctly', async ({ page }) => {
await page.goto('/workflows/test-failed');
const failedNode = page.locator('.node-failed').first();
await expect(failedNode).toBeVisible();
await expect(failedNode).toHaveScreenshot('node-failed.png');
});
test('displays skipped node correctly', async ({ page }) => {
await page.goto('/workflows/test-skipped');
const skippedNode = page.locator('.node-skipped').first();
await expect(skippedNode).toBeVisible();
await expect(skippedNode).toHaveScreenshot('node-skipped.png');
});
test('displays cancelled node correctly', async ({ page }) => {
await page.goto('/workflows/test-cancelled');
const cancelledNode = page.locator('.node-cancelled').first();
await expect(cancelledNode).toBeVisible();
await expect(cancelledNode).toHaveScreenshot('node-cancelled.png');
});
test('node state transition animation', async ({ page }) => {
await page.goto('/workflows/test-transition');
// Initial state - pending
const node = page.locator('[data-step-id="step-1"]');
await expect(node).toHaveClass(/node-pending/);
// Trigger transition
await page.click('[data-action="start-workflow"]');
// Wait for running state
await expect(node).toHaveClass(/node-running/, { timeout: 5000 });
await expect(node).toHaveScreenshot('node-transition-running.png');
// Wait for completed state
await expect(node).toHaveClass(/node-succeeded/, { timeout: 10000 });
await expect(node).toHaveScreenshot('node-transition-succeeded.png');
});
});
test.describe('Edge Rendering', () => {
test('renders static edges correctly', async ({ page }) => {
await page.goto('/workflows/test-static');
const edges = page.locator('.edge-path');
await expect(edges.first()).toBeVisible();
await expect(page.locator('.edges-layer')).toHaveScreenshot('edges-static.png');
});
test('renders animated edges for in-progress steps', async ({ page }) => {
await page.goto('/workflows/test-running');
const animatedEdge = page.locator('.edge.animated');
await expect(animatedEdge).toBeVisible();
// Verify animation is present (dash animation)
const edgePath = animatedEdge.locator('.edge-path');
const strokeDasharray = await edgePath.evaluate(el =>
window.getComputedStyle(el).getPropertyValue('stroke-dasharray')
);
expect(strokeDasharray).not.toBe('none');
});
test('highlights critical path edges', async ({ page }) => {
await page.goto('/workflows/test-completed');
// Enable critical path
await page.click('button:has-text("Critical Path")');
const criticalEdge = page.locator('.edge.critical');
await expect(criticalEdge.first()).toBeVisible();
await expect(page.locator('.edges-layer')).toHaveScreenshot('edges-critical-path.png');
});
});
test.describe('Layout Algorithms', () => {
test('dagre layout renders correctly', async ({ page }) => {
await page.goto('/workflows/test-layout');
await page.selectOption('.layout-selector select', 'dagre');
await page.waitForTimeout(500); // Wait for layout animation
await expect(page.locator('.dag-canvas')).toHaveScreenshot('layout-dagre.png');
});
test('elk layout renders correctly', async ({ page }) => {
await page.goto('/workflows/test-layout');
await page.selectOption('.layout-selector select', 'elk');
await page.waitForTimeout(500);
await expect(page.locator('.dag-canvas')).toHaveScreenshot('layout-elk.png');
});
test('force-directed layout renders correctly', async ({ page }) => {
await page.goto('/workflows/test-layout');
await page.selectOption('.layout-selector select', 'force');
await page.waitForTimeout(1000); // Force layout needs more time
await expect(page.locator('.dag-canvas')).toHaveScreenshot('layout-force.png', {
maxDiffPixelRatio: 0.1 // Force layout may have slight variations
});
});
});
test.describe('Zoom and Pan', () => {
test('zoom controls work correctly', async ({ page }) => {
await page.goto('/workflows/test-10-nodes');
// Zoom in
await page.click('button[title="Zoom In"]');
await page.click('button[title="Zoom In"]');
await expect(page.locator('.zoom-label')).toContainText('150%');
await expect(page.locator('.dag-canvas')).toHaveScreenshot('zoom-150.png');
// Zoom out
await page.click('button[title="Zoom Out"]');
await page.click('button[title="Zoom Out"]');
await page.click('button[title="Zoom Out"]');
await expect(page.locator('.zoom-label')).toContainText('75%');
await expect(page.locator('.dag-canvas')).toHaveScreenshot('zoom-75.png');
});
test('fit to view resets viewport', async ({ page }) => {
await page.goto('/workflows/test-10-nodes');
// Pan and zoom
await page.click('button[title="Zoom In"]');
await page.click('button[title="Zoom In"]');
// Fit to view
await page.click('button[title="Fit to View"]');
await expect(page.locator('.zoom-label')).toContainText('100%');
await expect(page.locator('.dag-canvas')).toHaveScreenshot('zoom-fit.png');
});
test('mouse wheel zooms', async ({ page }) => {
await page.goto('/workflows/test-10-nodes');
const canvas = page.locator('.canvas-container');
// Scroll to zoom
await canvas.hover();
await page.mouse.wheel(0, -100); // Zoom in
await page.waitForTimeout(200);
const zoomLabel = await page.locator('.zoom-label').textContent();
expect(parseInt(zoomLabel || '100')).toBeGreaterThan(100);
});
test('drag to pan', async ({ page }) => {
await page.goto('/workflows/test-50-nodes');
const canvas = page.locator('.canvas-container');
// Get initial viewbox
const initialViewBox = await page.locator('.dag-canvas').getAttribute('viewBox');
// Drag to pan
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 100, box.y + box.height / 2 + 50);
await page.mouse.up();
}
// Viewbox should have changed
const newViewBox = await page.locator('.dag-canvas').getAttribute('viewBox');
expect(newViewBox).not.toBe(initialViewBox);
});
});
test.describe('Node Selection', () => {
test('clicking node selects it', async ({ page }) => {
await page.goto('/workflows/test-10-nodes');
const node = page.locator('.node').first();
await node.click();
await expect(node).toHaveClass(/selected/);
await expect(node).toHaveScreenshot('node-selected.png');
});
test('double-clicking node opens details', async ({ page }) => {
await page.goto('/workflows/test-10-nodes');
const node = page.locator('.node').first();
await node.dblclick();
await expect(page.locator('.step-detail-panel')).toBeVisible();
});
});
test.describe('Minimap', () => {
test('minimap renders for large workflows', async ({ page }) => {
await page.goto('/workflows/test-50-nodes');
await expect(page.locator('.minimap')).toBeVisible();
await expect(page.locator('.minimap')).toHaveScreenshot('minimap.png');
});
test('minimap shows viewport indicator', async ({ page }) => {
await page.goto('/workflows/test-50-nodes');
await expect(page.locator('.viewport-indicator')).toBeVisible();
});
test('minimap hidden for small workflows', async ({ page }) => {
await page.goto('/workflows/test-5-nodes');
await expect(page.locator('.minimap')).not.toBeVisible();
});
});
test.describe('Responsive Layout', () => {
test('mobile viewport adjusts layout', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/workflows/test-10-nodes');
// Toolbar should wrap
await expect(page.locator('.visualizer-toolbar')).toHaveScreenshot('toolbar-mobile.png');
// Minimap should be hidden
await expect(page.locator('.minimap')).not.toBeVisible();
});
test('tablet viewport renders correctly', async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 });
await page.goto('/workflows/test-10-nodes');
await expect(page.locator('.workflow-visualizer')).toHaveScreenshot('visualizer-tablet.png');
});
test('desktop viewport renders correctly', async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 });
await page.goto('/workflows/test-10-nodes');
await expect(page.locator('.workflow-visualizer')).toHaveScreenshot('visualizer-desktop.png');
});
});
test.describe('Dark Mode', () => {
test('dark mode renders correctly', async ({ page }) => {
await page.goto('/workflows/test-10-nodes?theme=dark');
await expect(page.locator('.workflow-visualizer')).toHaveClass(/dark-mode/);
await expect(page.locator('.dag-canvas')).toHaveScreenshot('dag-dark-mode.png');
});
test('node states in dark mode', async ({ page }) => {
await page.goto('/workflows/test-all-states?theme=dark');
await expect(page.locator('.dag-canvas')).toHaveScreenshot('nodes-dark-mode.png');
});
});
test.describe('Legend', () => {
test('legend displays all states', async ({ page }) => {
await page.goto('/workflows/test-10-nodes');
const legend = page.locator('.legend');
await expect(legend).toBeVisible();
await expect(legend.locator('.legend-item')).toHaveCount(5);
await expect(legend).toHaveScreenshot('legend.png');
});
});
test.describe('Loading and Error States', () => {
test('loading overlay displays correctly', async ({ page }) => {
// Intercept API to delay response
await page.route('**/api/v1/workflows/*/graph', async route => {
await new Promise(resolve => setTimeout(resolve, 2000));
await route.continue();
});
await page.goto('/workflows/test-10-nodes');
await expect(page.locator('.loading-overlay')).toBeVisible();
await expect(page.locator('.loading-overlay')).toHaveScreenshot('loading-state.png');
});
test('error overlay displays correctly', async ({ page }) => {
// Mock API error
await page.route('**/api/v1/workflows/*/graph', async route => {
await route.fulfill({
status: 500,
body: JSON.stringify({ error: 'Internal Server Error' })
});
});
await page.goto('/workflows/test-10-nodes');
await expect(page.locator('.error-overlay')).toBeVisible();
await expect(page.locator('.error-overlay')).toHaveScreenshot('error-state.png');
});
});
});
test.describe('Time-Travel Controls', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/workflows/test-completed/debug');
await page.waitForSelector('.time-travel-controls');
});
test('controls render correctly', async ({ page }) => {
await expect(page.locator('.time-travel-controls')).toHaveScreenshot('time-travel-controls.png');
});
test('timeline with markers', async ({ page }) => {
await expect(page.locator('.timeline-container')).toHaveScreenshot('timeline-markers.png');
});
test('playhead position updates', async ({ page }) => {
// Step forward
await page.click('button[title*="Step Forward"]');
await page.click('button[title*="Step Forward"]');
await expect(page.locator('.timeline')).toHaveScreenshot('timeline-stepped.png');
});
});
test.describe('Step Detail Panel', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/workflows/test-completed');
await page.locator('.node').first().click();
await page.waitForSelector('.step-detail-panel');
});
test('panel renders correctly', async ({ page }) => {
await expect(page.locator('.step-detail-panel')).toHaveScreenshot('step-panel.png');
});
test('logs tab renders correctly', async ({ page }) => {
await page.click('.tab:has-text("Logs")');
await expect(page.locator('.logs-tab')).toHaveScreenshot('logs-tab.png');
});
test('timing tab renders correctly', async ({ page }) => {
await page.click('.tab:has-text("Timing")');
await expect(page.locator('.timing-tab')).toHaveScreenshot('timing-tab.png');
});
test('error state panel', async ({ page }) => {
await page.goto('/workflows/test-failed');
await page.locator('.node-failed').first().click();
await expect(page.locator('.step-detail-panel')).toHaveScreenshot('step-panel-error.png');
});
});

View File

@@ -0,0 +1,643 @@
// -----------------------------------------------------------------------------
// step-detail-panel.component.ts
// Sprint: SPRINT_20260117_032_ReleaseOrchestrator_workflow_visualization
// Task: TASK-032-10 - Step Detail Panel with Logs and Inspection
// Description: Panel showing step details, logs, inputs/outputs, and timing
// -----------------------------------------------------------------------------
import { Component, Input, Output, EventEmitter, OnInit, OnDestroy, OnChanges, SimpleChanges, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Subject, takeUntil, debounceTime, distinctUntilChanged } from 'rxjs';
import { WorkflowVisualizationService, StepDetails as ServiceStepDetails } from '../../services/workflow-visualization.service';
/**
* Step detail panel component.
* Shows comprehensive step information including logs, I/O, and timing.
*/
@Component({
selector: 'app-step-detail-panel',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="step-detail-panel" [class.dark-mode]="darkMode" [class.collapsed]="isCollapsed">
<!-- Header -->
<div class="panel-header" (click)="toggleCollapse()">
<div class="header-left">
@if (stepDetails) {
<span class="status-badge" [class]="'status-' + stepDetails.status.toLowerCase()">
{{ stepDetails.status }}
</span>
<h3 class="step-name">{{ stepDetails.stepName }}</h3>
<span class="step-type">{{ stepDetails.stepType }}</span>
} @else {
<h3 class="step-name">No Step Selected</h3>
}
</div>
<div class="header-right">
@if (stepDetails?.error) {
<span class="error-indicator" title="Step failed">⚠️</span>
}
<button class="btn btn-icon" [title]="isCollapsed ? 'Expand' : 'Collapse'">
<svg class="icon" [class.rotated]="!isCollapsed">
<use href="#icon-chevron-down"></use>
</svg>
</button>
</div>
</div>
@if (!isCollapsed && stepDetails) {
<!-- Tabs -->
<div class="panel-tabs">
<button
class="tab"
[class.active]="activeTab === 'overview'"
(click)="activeTab = 'overview'">
Overview
</button>
<button
class="tab"
[class.active]="activeTab === 'logs'"
(click)="activeTab = 'logs'">
Logs
@if (stepDetails.logSummary.errorCount > 0) {
<span class="badge error">{{ stepDetails.logSummary.errorCount }}</span>
}
</button>
<button
class="tab"
[class.active]="activeTab === 'inputs'"
(click)="activeTab = 'inputs'">
Inputs
</button>
<button
class="tab"
[class.active]="activeTab === 'outputs'"
(click)="activeTab = 'outputs'">
Outputs
</button>
<button
class="tab"
[class.active]="activeTab === 'timing'"
(click)="activeTab = 'timing'">
Timing
</button>
</div>
<!-- Tab Content -->
<div class="panel-content">
@switch (activeTab) {
<!-- Overview Tab -->
@case ('overview') {
<div class="overview-tab">
<!-- Error Alert -->
@if (stepDetails.error) {
<div class="error-alert">
<div class="error-header">
<span class="error-type">{{ stepDetails.error.type }}</span>
@if (stepDetails.error.isRetryable) {
<span class="retryable-badge">Retryable</span>
}
</div>
<p class="error-message">{{ stepDetails.error.message }}</p>
@if (stepDetails.retryCount > 0) {
<span class="retry-count">Retry attempts: {{ stepDetails.retryCount }}</span>
}
</div>
}
<!-- Quick Stats -->
<div class="stats-grid">
<div class="stat">
<span class="stat-label">Status</span>
<span class="stat-value" [class]="'status-' + stepDetails.status.toLowerCase()">
{{ stepDetails.status }}
</span>
</div>
<div class="stat">
<span class="stat-label">Duration</span>
<span class="stat-value">
{{ formatDuration(stepDetails.timing.executionTime) }}
</span>
</div>
<div class="stat">
<span class="stat-label">Queue Time</span>
<span class="stat-value">
{{ formatDuration(stepDetails.timing.queueTime) }}
</span>
</div>
<div class="stat">
<span class="stat-label">Log Lines</span>
<span class="stat-value">{{ stepDetails.logSummary.totalLines }}</span>
</div>
</div>
<!-- Dependencies -->
<div class="section">
<h4>Dependencies</h4>
<div class="dependency-lists">
<div class="dependency-group">
<span class="dep-label">Depends On:</span>
@if (stepDetails.dependencies.dependsOn.length > 0) {
<div class="dep-chips">
@for (dep of stepDetails.dependencies.dependsOn; track dep) {
<span class="dep-chip" (click)="selectStep(dep)">{{ dep }}</span>
}
</div>
} @else {
<span class="no-deps">None</span>
}
</div>
<div class="dependency-group">
<span class="dep-label">Blocks:</span>
@if (stepDetails.dependencies.blocks.length > 0) {
<div class="dep-chips">
@for (dep of stepDetails.dependencies.blocks; track dep) {
<span class="dep-chip" (click)="selectStep(dep)">{{ dep }}</span>
}
</div>
} @else {
<span class="no-deps">None</span>
}
</div>
</div>
</div>
<!-- Actions -->
@if (stepDetails.status === 'Failed' && stepDetails.error?.isRetryable) {
<div class="actions">
<button class="btn btn-primary" (click)="retryStep()">
Retry Step
</button>
</div>
}
</div>
}
<!-- Logs Tab -->
@case ('logs') {
<div class="logs-tab">
<!-- Log Filters -->
<div class="log-filters">
<div class="filter-group">
<label>Level:</label>
<select [(ngModel)]="logFilter.level" (ngModelChange)="onLogFilterChange()">
<option value="">All</option>
<option value="error">Error</option>
<option value="warning">Warning</option>
<option value="info">Info</option>
<option value="debug">Debug</option>
</select>
</div>
<div class="filter-group search">
<input
type="text"
placeholder="Search logs..."
[(ngModel)]="logFilter.search"
(ngModelChange)="onSearchChange($event)">
</div>
<button class="btn btn-sm" (click)="toggleAutoScroll()">
{{ autoScroll ? '⏸ Pause' : '▶ Auto-scroll' }}
</button>
</div>
<!-- Log Viewer -->
<div
class="log-viewer"
#logViewer
(scroll)="onLogScroll()">
@if (loadingLogs) {
<div class="loading-logs">Loading logs...</div>
} @else if (logs.length === 0) {
<div class="no-logs">No logs available</div>
} @else {
@for (log of logs; track $index) {
<div class="log-entry" [class]="'level-' + log.level.toLowerCase()">
<span class="log-time">{{ formatLogTime(log.timestamp) }}</span>
<span class="log-level">{{ log.level }}</span>
<span class="log-message">{{ log.message }}</span>
</div>
}
@if (hasMoreLogs) {
<button class="btn btn-sm load-more" (click)="loadMoreLogs()">
Load More
</button>
}
}
</div>
</div>
}
<!-- Inputs Tab -->
@case ('inputs') {
<div class="io-tab">
@if (stepDetails.inputs && Object.keys(stepDetails.inputs).length > 0) {
<div class="io-section">
<h4>Input Values</h4>
<div class="io-table">
@for (entry of getObjectEntries(stepDetails.inputs); track entry[0]) {
<div class="io-row">
<div class="io-key">{{ entry[0] }}</div>
<div class="io-value">
<pre>{{ formatValue(entry[1]) }}</pre>
</div>
@if (getInputSource(entry[0]); as source) {
<div class="io-source">
@switch (source.sourceType) {
@case ('StepOutput') {
<span class="source-badge step">
From: {{ source.sourceStepId }}
</span>
}
@case ('WorkflowInput') {
<span class="source-badge workflow">Workflow Input</span>
}
@default {
<span class="source-badge">{{ source.sourceType }}</span>
}
}
</div>
}
</div>
}
</div>
</div>
} @else {
<div class="no-data">No inputs for this step</div>
}
</div>
}
<!-- Outputs Tab -->
@case ('outputs') {
<div class="io-tab">
@if (stepDetails.outputs && Object.keys(stepDetails.outputs).length > 0) {
<div class="io-section">
<h4>Output Values</h4>
<div class="io-table">
@for (entry of getObjectEntries(stepDetails.outputs); track entry[0]) {
<div class="io-row">
<div class="io-key">{{ entry[0] }}</div>
<div class="io-value">
<pre>{{ formatValue(entry[1]) }}</pre>
</div>
@if (getOutputConsumers(entry[0]).length > 0) {
<div class="io-consumers">
<span class="consumers-label">Used by:</span>
@for (consumer of getOutputConsumers(entry[0]); track consumer.consumerStepId) {
<span
class="consumer-chip"
(click)="selectStep(consumer.consumerStepId)">
{{ consumer.consumerStepId }}
</span>
}
</div>
}
</div>
}
</div>
</div>
} @else {
<div class="no-data">
@if (stepDetails.status === 'Running') {
Step is still running...
} @else if (stepDetails.status === 'Failed') {
Step failed before producing outputs
} @else {
No outputs from this step
}
</div>
}
</div>
}
<!-- Timing Tab -->
@case ('timing') {
<div class="timing-tab">
<!-- Timeline View -->
<div class="timing-timeline">
<div class="timeline-bar">
@if (stepDetails.timing.queueTime) {
<div
class="segment queue"
[style.flex]="getTimeSegmentFlex('queue')"
title="Queue Time: {{ formatDuration(stepDetails.timing.queueTime) }}">
Queue
</div>
}
@if (stepDetails.timing.executionTime) {
<div
class="segment execution"
[style.flex]="getTimeSegmentFlex('execution')"
title="Execution Time: {{ formatDuration(stepDetails.timing.executionTime) }}">
Execution
</div>
}
</div>
</div>
<!-- Timing Details -->
<div class="timing-details">
<div class="timing-row">
<span class="timing-label">Queued At</span>
<span class="timing-value">
{{ stepDetails.timing.queuedAt ? formatTimestamp(stepDetails.timing.queuedAt) : 'N/A' }}
</span>
</div>
<div class="timing-row">
<span class="timing-label">Started At</span>
<span class="timing-value">
{{ stepDetails.timing.startedAt ? formatTimestamp(stepDetails.timing.startedAt) : 'N/A' }}
</span>
</div>
<div class="timing-row">
<span class="timing-label">Completed At</span>
<span class="timing-value">
{{ stepDetails.timing.completedAt ? formatTimestamp(stepDetails.timing.completedAt) : 'N/A' }}
</span>
</div>
<div class="timing-row highlight">
<span class="timing-label">Queue Time</span>
<span class="timing-value">
{{ formatDuration(stepDetails.timing.queueTime) }}
</span>
</div>
<div class="timing-row highlight">
<span class="timing-label">Execution Time</span>
<span class="timing-value">
{{ formatDuration(stepDetails.timing.executionTime) }}
</span>
</div>
</div>
</div>
}
}
</div>
}
</div>
`,
styleUrls: ['./step-detail-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
@Input() runId!: string;
@Input() stepId?: string;
@Input() darkMode = false;
@Output() stepSelected = new EventEmitter<string>();
@Output() retryRequested = new EventEmitter<string>();
// State
stepDetails: StepDetails | null = null;
isCollapsed = false;
activeTab: 'overview' | 'logs' | 'inputs' | 'outputs' | 'timing' = 'overview';
loading = false;
// Logs state
logs: LogEntry[] = [];
loadingLogs = false;
hasMoreLogs = false;
autoScroll = true;
logFilter = { level: '', search: '' };
private logPageToken?: string;
private readonly destroy$ = new Subject<void>();
private readonly searchSubject = new Subject<string>();
constructor(
private visualizationService: WorkflowVisualizationService,
private cdr: ChangeDetectorRef
) {}
ngOnInit(): void {
this.searchSubject
.pipe(
debounceTime(300),
distinctUntilChanged(),
takeUntil(this.destroy$)
)
.subscribe(() => this.loadLogs(true));
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['stepId'] && this.stepId) {
this.loadStepDetails();
}
}
loadStepDetails(): void {
if (!this.stepId) return;
this.loading = true;
this.visualizationService.getStepDetails(this.runId, this.stepId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (details) => {
this.stepDetails = details;
this.loading = false;
// Auto-switch to logs tab if there are errors
if (details.logSummary.errorCount > 0 && this.activeTab === 'overview') {
this.activeTab = 'logs';
}
this.loadLogs(true);
this.cdr.markForCheck();
},
error: (err) => {
console.error('Failed to load step details:', err);
this.loading = false;
this.cdr.markForCheck();
}
});
}
loadLogs(reset = false): void {
if (!this.stepId) return;
if (reset) {
this.logs = [];
this.logPageToken = undefined;
}
this.loadingLogs = true;
this.visualizationService.getStepLogs(this.runId, this.stepId, {
level: this.logFilter.level || undefined,
search: this.logFilter.search || undefined,
pageSize: 100,
pageToken: this.logPageToken
})
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (result) => {
this.logs = reset ? result.logs : [...this.logs, ...result.logs];
this.logPageToken = result.nextPageToken;
this.hasMoreLogs = !!result.nextPageToken;
this.loadingLogs = false;
this.cdr.markForCheck();
},
error: (err) => {
console.error('Failed to load logs:', err);
this.loadingLogs = false;
this.cdr.markForCheck();
}
});
}
loadMoreLogs(): void {
this.loadLogs(false);
}
// Event handlers
toggleCollapse(): void {
this.isCollapsed = !this.isCollapsed;
}
onLogFilterChange(): void {
this.loadLogs(true);
}
onSearchChange(search: string): void {
this.searchSubject.next(search);
}
onLogScroll(): void {
// Disable auto-scroll when user scrolls up
this.autoScroll = false;
}
toggleAutoScroll(): void {
this.autoScroll = !this.autoScroll;
}
selectStep(stepId: string): void {
this.stepSelected.emit(stepId);
}
retryStep(): void {
if (this.stepId) {
this.retryRequested.emit(this.stepId);
}
}
// Helpers
getObjectEntries(obj: Record<string, any> | null): [string, any][] {
return obj ? Object.entries(obj) : [];
}
getInputSource(inputKey: string): any | null {
return this.stepDetails?.inputSources?.find(s => s.inputKey === inputKey) || null;
}
getOutputConsumers(outputKey: string): any[] {
return this.stepDetails?.outputConsumers?.filter(c => c.outputKey === outputKey) || [];
}
formatValue(value: any): string {
if (typeof value === 'string') return value;
return JSON.stringify(value, null, 2);
}
formatDuration(duration: string | number | null | undefined): string {
if (!duration) return 'N/A';
// Handle ISO duration strings
if (typeof duration === 'string') {
// Parse ISO 8601 duration or timespan format
const match = duration.match(/(\d+):(\d+):(\d+)\.?(\d+)?/);
if (match) {
const [, hours, minutes, seconds] = match;
const totalMs = (parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds)) * 1000;
return this.formatMs(totalMs);
}
return duration;
}
return this.formatMs(duration);
}
private formatMs(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(2)}s`;
if (ms < 3600000) return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
return `${Math.floor(ms / 3600000)}h ${Math.floor((ms % 3600000) / 60000)}m`;
}
formatTimestamp(timestamp: string): string {
return new Date(timestamp).toLocaleString();
}
formatLogTime(timestamp: string): string {
return new Date(timestamp).toLocaleTimeString();
}
getTimeSegmentFlex(segment: 'queue' | 'execution'): number {
if (!this.stepDetails?.timing) return 0;
const queueTime = this.parseDuration(this.stepDetails.timing.queueTime);
const execTime = this.parseDuration(this.stepDetails.timing.executionTime);
const total = queueTime + execTime;
if (total === 0) return segment === 'execution' ? 1 : 0;
return segment === 'queue' ? queueTime / total : execTime / total;
}
private parseDuration(duration: string | number | null | undefined): number {
if (!duration) return 0;
if (typeof duration === 'number') return duration;
const match = duration.match(/(\d+):(\d+):(\d+)\.?(\d+)?/);
if (match) {
const [, hours, minutes, seconds, ms] = match;
return (parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds)) * 1000 + (parseInt(ms) || 0);
}
return 0;
}
}
interface LogEntry {
timestamp: string;
level: string;
message: string;
}
interface StepDetails {
runId: string;
stepId: string;
stepName: string;
stepType: string;
status: string;
inputs: Record<string, any> | null;
outputs: Record<string, any> | null;
inputSources: any[];
outputConsumers: any[];
timing: {
queuedAt: string | null;
startedAt: string | null;
completedAt: string | null;
queueTime: string | null;
executionTime: string | null;
};
dependencies: {
dependsOn: string[];
blocks: string[];
blockedBy: string[];
};
logSummary: {
totalLines: number;
errorCount: number;
warningCount: number;
};
error: {
message: string;
type: string;
isRetryable: boolean;
} | null;
retryCount: number;
}

View File

@@ -0,0 +1,524 @@
// -----------------------------------------------------------------------------
// time-travel-controls.component.ts
// Sprint: SPRINT_20260117_032_ReleaseOrchestrator_workflow_visualization
// Task: TASK-032-09 - Time-Travel UI Component
// Description: Controls for time-travel debugging with playback and timeline
// -----------------------------------------------------------------------------
import { Component, Input, Output, EventEmitter, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Subject, takeUntil, interval, timer } from 'rxjs';
import { TimeTravelService, DebugSession, SnapshotSummary, SnapshotState } from '../../services/time-travel.service';
/**
* Time-travel debugging controls component.
* Provides playback, timeline scrubbing, and snapshot navigation.
*/
@Component({
selector: 'app-time-travel-controls',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="time-travel-controls" [class.dark-mode]="darkMode">
<!-- Session Info -->
@if (session) {
<div class="session-info">
<span class="session-label">Debug Session</span>
<span class="session-id">{{ session.sessionId | slice:0:8 }}...</span>
<span class="session-expiry" [class.expiring-soon]="isExpiringSoon()">
Expires {{ formatExpiry() }}
</span>
</div>
}
<!-- Playback Controls -->
<div class="playback-controls">
<button
class="btn btn-icon"
(click)="jumpToStart()"
[disabled]="currentIndex === 0 || loading"
title="Jump to Start (Home)">
<svg class="icon"><use href="#icon-skip-start"></use></svg>
</button>
<button
class="btn btn-icon"
(click)="stepBackward()"
[disabled]="currentIndex === 0 || loading"
title="Step Backward (←)">
<svg class="icon"><use href="#icon-step-back"></use></svg>
</button>
<button
class="btn btn-icon btn-primary"
(click)="togglePlayback()"
[disabled]="loading || currentIndex >= totalSnapshots - 1"
title="{{ isPlaying ? 'Pause (Space)' : 'Play (Space)' }}">
<svg class="icon">
<use [attr.href]="isPlaying ? '#icon-pause' : '#icon-play'"></use>
</svg>
</button>
<button
class="btn btn-icon"
(click)="stepForward()"
[disabled]="currentIndex >= totalSnapshots - 1 || loading"
title="Step Forward (→)">
<svg class="icon"><use href="#icon-step-forward"></use></svg>
</button>
<button
class="btn btn-icon"
(click)="jumpToEnd()"
[disabled]="currentIndex >= totalSnapshots - 1 || loading"
title="Jump to End (End)">
<svg class="icon"><use href="#icon-skip-end"></use></svg>
</button>
</div>
<!-- Speed Control -->
<div class="speed-control">
<label>Speed:</label>
<select [(ngModel)]="playbackSpeed" (ngModelChange)="onSpeedChange()">
<option [value]="0.25">0.25x</option>
<option [value]="0.5">0.5x</option>
<option [value]="1">1x</option>
<option [value]="2">2x</option>
<option [value]="4">4x</option>
</select>
</div>
<!-- Position Indicator -->
<div class="position-indicator">
<span class="current">{{ currentIndex + 1 }}</span>
<span class="separator">/</span>
<span class="total">{{ totalSnapshots }}</span>
</div>
<!-- Timeline Scrubber -->
<div class="timeline-container">
<div class="timeline" #timeline (click)="onTimelineClick($event)">
<!-- Timeline Track -->
<div class="timeline-track">
<div
class="timeline-progress"
[style.width.%]="progressPercentage">
</div>
</div>
<!-- Snapshot Markers -->
<div class="snapshot-markers">
@for (snapshot of snapshots; track snapshot.index) {
<div
class="snapshot-marker"
[class]="'marker-' + getEventCategory(snapshot.eventType)"
[class.current]="snapshot.index === currentIndex"
[class.error]="snapshot.eventType.includes('failed')"
[style.left.%]="getMarkerPosition(snapshot.index)"
[title]="getMarkerTooltip(snapshot)"
(click)="jumpToSnapshot(snapshot.index, $event)">
</div>
}
</div>
<!-- Playhead -->
<div
class="playhead"
[style.left.%]="progressPercentage"
[class.dragging]="isDraggingPlayhead"
(mousedown)="onPlayheadDragStart($event)">
<div class="playhead-handle"></div>
</div>
</div>
<!-- Time Labels -->
<div class="time-labels">
<span class="time-start">{{ formatTimestamp(startTime) }}</span>
@if (currentSnapshot) {
<span class="time-current">{{ formatTimestamp(currentSnapshot.timestamp) }}</span>
}
<span class="time-end">{{ formatTimestamp(endTime) }}</span>
</div>
</div>
<!-- Current Snapshot Info -->
@if (currentSnapshot) {
<div class="current-snapshot-info">
<span class="event-type" [class]="'event-' + getEventCategory(currentSnapshot.eventType)">
{{ formatEventType(currentSnapshot.eventType) }}
</span>
@if (currentSnapshot.stepId) {
<span class="step-id">Step: {{ currentSnapshot.stepId }}</span>
}
</div>
}
<!-- Diff View Toggle -->
<div class="diff-toggle">
<label class="toggle-label">
<input
type="checkbox"
[(ngModel)]="showDiff"
(ngModelChange)="onShowDiffChange()">
<span class="toggle-text">Show Changes</span>
</label>
</div>
</div>
<!-- Diff Panel -->
@if (showDiff && currentState?.diff) {
<div class="diff-panel" [class.dark-mode]="darkMode">
<div class="diff-header">
<h4>Changes at Snapshot {{ currentIndex + 1 }}</h4>
<button class="btn btn-sm" (click)="showDiff = false">Close</button>
</div>
<div class="diff-content">
<pre class="diff-json">{{ formatDiff(currentState.diff) }}</pre>
</div>
</div>
}
`,
styleUrls: ['./time-travel-controls.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TimeTravelControlsComponent implements OnInit, OnDestroy {
@Input() runId!: string;
@Input() sessionId?: string;
@Input() darkMode = false;
@Output() snapshotChanged = new EventEmitter<SnapshotState>();
@Output() sessionCreated = new EventEmitter<DebugSession>();
@Output() sessionExpired = new EventEmitter<void>();
// Session state
session: DebugSession | null = null;
snapshots: SnapshotSummary[] = [];
currentSnapshot: SnapshotSummary | null = null;
currentState: SnapshotState | null = null;
// Playback state
isPlaying = false;
playbackSpeed = 1;
currentIndex = 0;
totalSnapshots = 0;
loading = false;
// Timeline state
isDraggingPlayhead = false;
startTime: Date | null = null;
endTime: Date | null = null;
// UI state
showDiff = false;
private readonly destroy$ = new Subject<void>();
private playbackInterval$ = new Subject<void>();
constructor(
private timeTravelService: TimeTravelService,
private cdr: ChangeDetectorRef
) {}
ngOnInit(): void {
if (this.sessionId) {
this.loadSession(this.sessionId);
} else {
this.createSession();
}
// Keyboard shortcuts
document.addEventListener('keydown', this.handleKeydown.bind(this));
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
this.playbackInterval$.complete();
document.removeEventListener('keydown', this.handleKeydown.bind(this));
}
get progressPercentage(): number {
if (this.totalSnapshots <= 1) return 0;
return (this.currentIndex / (this.totalSnapshots - 1)) * 100;
}
// Session management
createSession(): void {
this.loading = true;
this.timeTravelService.createSession(this.runId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (session) => {
this.session = session;
this.totalSnapshots = session.totalSnapshots;
this.currentIndex = session.currentSnapshotIndex;
this.sessionCreated.emit(session);
this.loadSnapshots();
},
error: (err) => {
console.error('Failed to create debug session:', err);
this.loading = false;
this.cdr.markForCheck();
}
});
}
loadSession(sessionId: string): void {
this.loading = true;
this.timeTravelService.getSession(sessionId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (session) => {
if (session) {
this.session = session;
this.totalSnapshots = session.totalSnapshots;
this.currentIndex = session.currentSnapshotIndex;
this.loadSnapshots();
} else {
this.createSession();
}
},
error: () => this.createSession()
});
}
loadSnapshots(): void {
if (!this.session) return;
this.timeTravelService.getSnapshots(this.session.sessionId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (snapshots) => {
this.snapshots = snapshots;
if (snapshots.length > 0) {
this.startTime = new Date(snapshots[0].timestamp);
this.endTime = new Date(snapshots[snapshots.length - 1].timestamp);
this.currentSnapshot = snapshots[this.currentIndex];
}
this.loading = false;
this.cdr.markForCheck();
},
error: (err) => {
console.error('Failed to load snapshots:', err);
this.loading = false;
this.cdr.markForCheck();
}
});
}
// Navigation
stepForward(): void {
if (this.currentIndex >= this.totalSnapshots - 1) return;
this.navigateTo(this.currentIndex + 1);
}
stepBackward(): void {
if (this.currentIndex <= 0) return;
this.navigateTo(this.currentIndex - 1);
}
jumpToStart(): void {
this.navigateTo(0);
}
jumpToEnd(): void {
this.navigateTo(this.totalSnapshots - 1);
}
jumpToSnapshot(index: number, event?: MouseEvent): void {
event?.stopPropagation();
this.navigateTo(index);
}
private navigateTo(index: number): void {
if (!this.session || this.loading) return;
this.loading = true;
this.timeTravelService.jumpToSnapshot(this.session.sessionId, index)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (state) => {
this.currentIndex = state.snapshotIndex;
this.currentSnapshot = this.snapshots[this.currentIndex];
this.currentState = state;
this.snapshotChanged.emit(state);
this.loading = false;
this.cdr.markForCheck();
},
error: (err) => {
console.error('Navigation failed:', err);
this.loading = false;
this.cdr.markForCheck();
}
});
}
// Playback
togglePlayback(): void {
if (this.isPlaying) {
this.pausePlayback();
} else {
this.startPlayback();
}
}
startPlayback(): void {
if (this.currentIndex >= this.totalSnapshots - 1) return;
this.isPlaying = true;
const intervalMs = 1000 / this.playbackSpeed;
interval(intervalMs)
.pipe(takeUntil(this.playbackInterval$), takeUntil(this.destroy$))
.subscribe(() => {
if (this.currentIndex >= this.totalSnapshots - 1) {
this.pausePlayback();
return;
}
this.stepForward();
});
}
pausePlayback(): void {
this.isPlaying = false;
this.playbackInterval$.next();
}
onSpeedChange(): void {
if (this.isPlaying) {
this.pausePlayback();
this.startPlayback();
}
}
// Timeline interaction
onTimelineClick(event: MouseEvent): void {
const timeline = event.currentTarget as HTMLElement;
const rect = timeline.getBoundingClientRect();
const clickX = event.clientX - rect.left;
const percentage = clickX / rect.width;
const targetIndex = Math.round(percentage * (this.totalSnapshots - 1));
this.jumpToSnapshot(Math.max(0, Math.min(targetIndex, this.totalSnapshots - 1)));
}
onPlayheadDragStart(event: MouseEvent): void {
event.preventDefault();
this.isDraggingPlayhead = true;
const onMouseMove = (e: MouseEvent) => {
if (!this.isDraggingPlayhead) return;
const timeline = document.querySelector('.timeline') as HTMLElement;
if (!timeline) return;
const rect = timeline.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
const percentage = x / rect.width;
const targetIndex = Math.round(percentage * (this.totalSnapshots - 1));
if (targetIndex !== this.currentIndex) {
this.jumpToSnapshot(targetIndex);
}
};
const onMouseUp = () => {
this.isDraggingPlayhead = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
// Keyboard shortcuts
private handleKeydown(event: KeyboardEvent): void {
if (event.target instanceof HTMLInputElement) return;
switch (event.key) {
case 'ArrowLeft':
event.preventDefault();
this.stepBackward();
break;
case 'ArrowRight':
event.preventDefault();
this.stepForward();
break;
case 'Home':
event.preventDefault();
this.jumpToStart();
break;
case 'End':
event.preventDefault();
this.jumpToEnd();
break;
case ' ':
event.preventDefault();
this.togglePlayback();
break;
}
}
// Helpers
getMarkerPosition(index: number): number {
if (this.totalSnapshots <= 1) return 0;
return (index / (this.totalSnapshots - 1)) * 100;
}
getEventCategory(eventType: string): string {
if (eventType.includes('started')) return 'started';
if (eventType.includes('completed') || eventType.includes('succeeded')) return 'completed';
if (eventType.includes('failed')) return 'failed';
if (eventType.includes('queued')) return 'queued';
return 'other';
}
getMarkerTooltip(snapshot: SnapshotSummary): string {
let tooltip = `${this.formatEventType(snapshot.eventType)}\n`;
tooltip += `Time: ${this.formatTimestamp(snapshot.timestamp)}`;
if (snapshot.stepId) {
tooltip += `\nStep: ${snapshot.stepId}`;
}
return tooltip;
}
formatEventType(eventType: string): string {
return eventType
.split('.')
.map(s => s.charAt(0).toUpperCase() + s.slice(1))
.join(' → ');
}
formatTimestamp(timestamp: Date | string | null): string {
if (!timestamp) return '--:--:--';
const date = timestamp instanceof Date ? timestamp : new Date(timestamp);
return date.toLocaleTimeString();
}
formatExpiry(): string {
if (!this.session) return '';
const expiry = new Date(this.session.expiresAt);
const now = new Date();
const diffMs = expiry.getTime() - now.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins <= 0) return 'expired';
if (diffMins < 60) return `in ${diffMins}m`;
return `in ${Math.floor(diffMins / 60)}h ${diffMins % 60}m`;
}
isExpiringSoon(): boolean {
if (!this.session) return false;
const expiry = new Date(this.session.expiresAt);
const now = new Date();
return (expiry.getTime() - now.getTime()) < 5 * 60 * 1000; // 5 minutes
}
formatDiff(diff: any): string {
return JSON.stringify(diff, null, 2);
}
onShowDiffChange(): void {
// Could load diff data if not already loaded
}
}

View File

@@ -0,0 +1,367 @@
// -----------------------------------------------------------------------------
// workflow-visualizer.component.scss
// Sprint: SPRINT_20260117_032_ReleaseOrchestrator_workflow_visualization
// Task: TASK-032-08 - DAG Visualization UI Styles
// -----------------------------------------------------------------------------
:host {
--color-primary: #3b82f6;
--color-pending: #9ca3af;
--color-pending-bg: #f3f4f6;
--color-pending-stroke: #d1d5db;
--color-queued-bg: #fef3c7;
--color-queued-stroke: #f59e0b;
--color-running: #3b82f6;
--color-running-bg: #dbeafe;
--color-success: #10b981;
--color-success-bg: #d1fae5;
--color-error: #ef4444;
--color-error-bg: #fee2e2;
--color-skipped: #6b7280;
--color-skipped-bg: #e5e7eb;
--color-skipped-stroke: #9ca3af;
--color-cancelled: #78716c;
--color-cancelled-bg: #e7e5e4;
--color-cancelled-stroke: #a8a29e;
--color-critical: #f59e0b;
--color-edge: #9ca3af;
--color-text: #1f2937;
--color-text-light: #ffffff;
--color-badge-bg: rgba(0, 0, 0, 0.6);
--color-badge-text: #ffffff;
--color-default-bg: #f9fafb;
--color-default-stroke: #d1d5db;
display: block;
width: 100%;
height: 100%;
}
.workflow-visualizer {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: var(--color-bg, #ffffff);
border-radius: 8px;
overflow: hidden;
&.dark-mode {
--color-bg: #1f2937;
--color-text: #f9fafb;
--color-pending-bg: #374151;
--color-pending-stroke: #4b5563;
--color-running-bg: #1e3a5f;
--color-success-bg: #064e3b;
--color-error-bg: #7f1d1d;
--color-skipped-bg: #374151;
--color-edge: #6b7280;
--color-default-bg: #374151;
--color-default-stroke: #4b5563;
}
}
.visualizer-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: var(--color-toolbar-bg, #f9fafb);
border-bottom: 1px solid var(--color-border, #e5e7eb);
.toolbar-left,
.toolbar-center,
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.btn {
padding: 6px 12px;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 6px;
background: white;
cursor: pointer;
font-size: 13px;
transition: all 0.15s ease;
&:hover:not(:disabled) {
background: var(--color-hover, #f3f4f6);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&.active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
&.btn-icon {
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
.icon {
width: 16px;
height: 16px;
}
}
&.btn-sm {
padding: 4px 10px;
font-size: 12px;
}
}
.zoom-label {
font-size: 12px;
color: var(--color-text-muted, #6b7280);
min-width: 40px;
text-align: center;
}
.layout-selector select {
padding: 6px 24px 6px 12px;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 6px;
background: white;
font-size: 13px;
cursor: pointer;
}
}
.canvas-container {
flex: 1;
position: relative;
overflow: hidden;
}
.dag-canvas {
width: 100%;
height: 100%;
user-select: none;
}
.edges-layer {
.edge-path {
transition: stroke 0.2s ease, stroke-width 0.2s ease;
}
.edge.critical .edge-path {
filter: url(#glow);
}
.edge.animated .edge-path {
stroke-dasharray: 8 4;
animation: dash 0.5s linear infinite;
}
}
@keyframes dash {
to {
stroke-dashoffset: -12;
}
}
.nodes-layer {
.node {
cursor: pointer;
transition: transform 0.15s ease;
&:hover {
transform: translateY(-2px);
.node-rect {
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.1));
}
}
&.selected .node-rect {
stroke-width: 3;
filter: drop-shadow(0 0 8px var(--color-primary));
}
&.critical .node-rect {
filter: url(#glow);
}
}
.node-rect {
transition: all 0.2s ease;
}
.node-label {
font-size: 13px;
font-weight: 500;
pointer-events: none;
}
.duration-badge {
opacity: 0.9;
}
.pulse {
animation: pulse 1.5s ease-in-out infinite;
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.2);
}
}
.loading-overlay,
.error-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
background: rgba(255, 255, 255, 0.9);
z-index: 10;
}
.loading-overlay {
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-border, #e5e7eb);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
span {
color: var(--color-text-muted, #6b7280);
font-size: 14px;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error-overlay {
.error-icon {
font-size: 32px;
color: var(--color-error);
}
.error-message {
color: var(--color-error);
font-size: 14px;
}
}
.minimap {
position: absolute;
bottom: 16px;
right: 16px;
background: rgba(255, 255, 255, 0.95);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
z-index: 5;
svg {
width: 100%;
height: 100%;
}
.viewport-indicator {
stroke-dasharray: 4 2;
}
}
.legend {
position: absolute;
bottom: 16px;
left: 16px;
display: flex;
gap: 16px;
padding: 8px 16px;
background: rgba(255, 255, 255, 0.95);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 5;
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--color-text-muted, #6b7280);
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
&.pending {
background: var(--color-pending);
}
&.running {
background: var(--color-running);
animation: pulse 1.5s ease-in-out infinite;
}
&.succeeded {
background: var(--color-success);
}
&.failed {
background: var(--color-error);
}
&.skipped {
background: var(--color-skipped);
}
}
}
// Responsive adjustments
@media (max-width: 768px) {
.visualizer-toolbar {
flex-wrap: wrap;
gap: 8px;
.toolbar-center {
order: 3;
width: 100%;
justify-content: center;
}
}
.legend {
flex-wrap: wrap;
gap: 8px;
}
.minimap {
display: none;
}
}

View File

@@ -0,0 +1,616 @@
// -----------------------------------------------------------------------------
// workflow-visualizer.component.ts
// Sprint: SPRINT_20260117_032_ReleaseOrchestrator_workflow_visualization
// Task: TASK-032-08 - DAG Visualization UI
// Description: React Flow based workflow DAG visualization component
// -----------------------------------------------------------------------------
import { Component, Input, Output, EventEmitter, OnInit, OnDestroy, ViewChild, ElementRef, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Subject, takeUntil, interval, switchMap, filter } from 'rxjs';
import { WorkflowVisualizationService, WorkflowGraph, GraphNode, GraphEdge, NodePosition } from '../../services/workflow-visualization.service';
/**
* DAG visualization component for workflow execution.
* Uses canvas-based rendering for performance with large graphs.
*/
@Component({
selector: 'app-workflow-visualizer',
standalone: true,
imports: [CommonModule],
template: `
<div class="workflow-visualizer" [class.dark-mode]="darkMode">
<!-- Toolbar -->
<div class="visualizer-toolbar">
<div class="toolbar-left">
<button
class="btn btn-icon"
(click)="zoomIn()"
[disabled]="zoom >= maxZoom"
title="Zoom In">
<svg class="icon">
<use href="#icon-zoom-in"></use>
</svg>
</button>
<button
class="btn btn-icon"
(click)="zoomOut()"
[disabled]="zoom <= minZoom"
title="Zoom Out">
<svg class="icon">
<use href="#icon-zoom-out"></use>
</svg>
</button>
<button
class="btn btn-icon"
(click)="fitView()"
title="Fit to View">
<svg class="icon">
<use href="#icon-fit-view"></use>
</svg>
</button>
<span class="zoom-label">{{ (zoom * 100).toFixed(0) }}%</span>
</div>
<div class="toolbar-center">
<button
class="btn btn-sm"
[class.active]="showCriticalPath"
(click)="toggleCriticalPath()">
Critical Path
</button>
<button
class="btn btn-sm"
[class.active]="showTimeline"
(click)="toggleTimeline()">
Timeline
</button>
</div>
<div class="toolbar-right">
<div class="layout-selector">
<select [(ngModel)]="layoutAlgorithm" (ngModelChange)="onLayoutChange()">
<option value="dagre">Dagre (Top-Down)</option>
<option value="elk">ELK (Layered)</option>
<option value="force">Force-Directed</option>
</select>
</div>
</div>
</div>
<!-- Canvas Container -->
<div
#canvasContainer
class="canvas-container"
(wheel)="onWheel($event)"
(mousedown)="onMouseDown($event)"
(mousemove)="onMouseMove($event)"
(mouseup)="onMouseUp()"
(mouseleave)="onMouseUp()">
<svg
#svgCanvas
class="dag-canvas"
[attr.viewBox]="viewBox"
[style.cursor]="isDragging ? 'grabbing' : 'grab'">
<!-- Definitions for markers and gradients -->
<defs>
<!-- Arrow marker for edges -->
<marker
id="arrowhead"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="currentColor" />
</marker>
<!-- Animated arrow for running edges -->
<marker
id="arrowhead-animated"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="var(--color-running)" />
</marker>
<!-- Glow filter for critical path -->
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- Edges Layer -->
<g class="edges-layer">
@for (edge of edges; track edge.id) {
<g class="edge"
[class.critical]="showCriticalPath && criticalPathEdges.has(edge.id)"
[class.animated]="edge.isAnimated">
<path
[attr.d]="getEdgePath(edge)"
[attr.stroke]="getEdgeColor(edge)"
[attr.stroke-width]="getEdgeWidth(edge)"
[attr.marker-end]="edge.isAnimated ? 'url(#arrowhead-animated)' : 'url(#arrowhead)'"
fill="none"
class="edge-path">
</path>
<!-- Animated dots for running edges -->
@if (edge.isAnimated) {
<circle r="4" [attr.fill]="'var(--color-running)'">
<animateMotion
[attr.path]="getEdgePath(edge)"
dur="1s"
repeatCount="indefinite" />
</circle>
}
</g>
}
</g>
<!-- Nodes Layer -->
<g class="nodes-layer">
@for (node of nodes; track node.id) {
<g
class="node"
[class]="'node-' + node.status.toLowerCase()"
[class.selected]="selectedNodeId === node.id"
[class.critical]="showCriticalPath && criticalPathNodes.has(node.id)"
[attr.transform]="getNodeTransform(node)"
(click)="onNodeClick(node, $event)"
(dblclick)="onNodeDoubleClick(node)">
<!-- Node background -->
<rect
[attr.width]="nodeWidth"
[attr.height]="nodeHeight"
[attr.rx]="8"
[attr.ry]="8"
[attr.fill]="getNodeFill(node)"
[attr.stroke]="getNodeStroke(node)"
[attr.stroke-width]="selectedNodeId === node.id ? 3 : 2"
class="node-rect"
/>
<!-- Status icon -->
<g [attr.transform]="'translate(12, ' + (nodeHeight / 2) + ')'">
@switch (node.status) {
@case ('Running') {
<circle r="6" fill="var(--color-running)" class="pulse" />
}
@case ('Succeeded') {
<circle r="8" fill="var(--color-success)">
<text x="0" y="3" text-anchor="middle" fill="white" font-size="10">✓</text>
</circle>
}
@case ('Failed') {
<circle r="8" fill="var(--color-error)">
<text x="0" y="3" text-anchor="middle" fill="white" font-size="10">✕</text>
</circle>
}
@case ('Pending') {
<circle r="6" fill="var(--color-pending)" stroke="var(--color-pending-stroke)" stroke-width="2" />
}
@case ('Skipped') {
<circle r="6" fill="var(--color-skipped)" />
}
}
</g>
<!-- Node label -->
<text
[attr.x]="nodeWidth / 2"
[attr.y]="nodeHeight / 2 + 4"
text-anchor="middle"
class="node-label"
[attr.fill]="getNodeTextColor(node)">
{{ truncateLabel(node.label) }}
</text>
<!-- Duration badge (if completed) -->
@if (node.data?.['duration']) {
<g [attr.transform]="'translate(' + (nodeWidth - 8) + ', 8)'">
<rect
x="-24" y="-8"
width="32" height="16"
rx="8" ry="8"
fill="var(--color-badge-bg)"
class="duration-badge" />
<text x="-8" y="4" text-anchor="middle" font-size="9" fill="var(--color-badge-text)">
{{ formatDuration(node.data?.['duration']) }}
</text>
</g>
}
</g>
}
</g>
</svg>
<!-- Loading Overlay -->
@if (loading) {
<div class="loading-overlay">
<div class="spinner"></div>
<span>Loading workflow...</span>
</div>
}
<!-- Error Overlay -->
@if (error) {
<div class="error-overlay">
<span class="error-icon">⚠</span>
<span class="error-message">{{ error }}</span>
<button class="btn btn-sm" (click)="retry()">Retry</button>
</div>
}
</div>
<!-- Minimap -->
@if (showMinimap && nodes.length > 10) {
<div class="minimap" [style.width.px]="minimapWidth" [style.height.px]="minimapHeight">
<svg [attr.viewBox]="minimapViewBox">
@for (node of nodes; track node.id) {
<rect
[attr.x]="getMinimapX(node)"
[attr.y]="getMinimapY(node)"
[attr.width]="minimapNodeSize"
[attr.height]="minimapNodeSize"
[attr.fill]="getNodeFill(node)"
rx="2" />
}
<rect
class="viewport-indicator"
[attr.x]="viewportX"
[attr.y]="viewportY"
[attr.width]="viewportWidth"
[attr.height]="viewportHeight"
fill="none"
stroke="var(--color-primary)"
stroke-width="2" />
</svg>
</div>
}
<!-- Legend -->
<div class="legend">
<div class="legend-item">
<span class="legend-dot pending"></span>
<span>Pending</span>
</div>
<div class="legend-item">
<span class="legend-dot running"></span>
<span>Running</span>
</div>
<div class="legend-item">
<span class="legend-dot succeeded"></span>
<span>Succeeded</span>
</div>
<div class="legend-item">
<span class="legend-dot failed"></span>
<span>Failed</span>
</div>
<div class="legend-item">
<span class="legend-dot skipped"></span>
<span>Skipped</span>
</div>
</div>
</div>
`,
styleUrls: ['./workflow-visualizer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class WorkflowVisualizerComponent implements OnInit, OnDestroy {
@ViewChild('canvasContainer') canvasContainer!: ElementRef<HTMLDivElement>;
@ViewChild('svgCanvas') svgCanvas!: ElementRef<SVGElement>;
@Input() runId!: string;
@Input() darkMode = false;
@Input() showMinimap = true;
@Input() autoRefresh = true;
@Input() refreshInterval = 2000;
@Output() nodeSelected = new EventEmitter<GraphNode>();
@Output() nodeDoubleClicked = new EventEmitter<GraphNode>();
@Output() graphLoaded = new EventEmitter<WorkflowGraph>();
// Graph data
nodes: GraphNode[] = [];
edges: GraphEdge[] = [];
positions: Map<string, NodePosition> = new Map();
// View state
zoom = 1;
panX = 0;
panY = 0;
isDragging = false;
dragStartX = 0;
dragStartY = 0;
// Configuration
nodeWidth = 180;
nodeHeight = 60;
minZoom = 0.25;
maxZoom = 2;
layoutAlgorithm = 'dagre';
// UI state
loading = false;
error: string | null = null;
selectedNodeId: string | null = null;
showCriticalPath = false;
showTimeline = false;
// Critical path
criticalPathNodes = new Set<string>();
criticalPathEdges = new Set<string>();
// Minimap
minimapWidth = 150;
minimapHeight = 100;
minimapNodeSize = 8;
minimapViewBox = '0 0 1000 600';
viewportX = 0;
viewportY = 0;
viewportWidth = 200;
viewportHeight = 120;
private readonly destroy$ = new Subject<void>();
constructor(private visualizationService: WorkflowVisualizationService) {}
ngOnInit(): void {
this.loadGraph();
if (this.autoRefresh) {
interval(this.refreshInterval)
.pipe(
takeUntil(this.destroy$),
filter(() => !this.loading),
switchMap(() => this.visualizationService.getGraph(this.runId))
)
.subscribe({
next: (graph) => this.updateGraph(graph),
error: (err) => console.error('Auto-refresh failed:', err)
});
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
get viewBox(): string {
const width = 1200 / this.zoom;
const height = 800 / this.zoom;
return `${-this.panX / this.zoom} ${-this.panY / this.zoom} ${width} ${height}`;
}
loadGraph(): void {
this.loading = true;
this.error = null;
this.visualizationService.getLayoutedGraph(this.runId, this.layoutAlgorithm)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (graph) => {
this.nodes = graph.nodes;
this.edges = graph.edges;
this.positions.clear();
graph.positions.forEach(p => this.positions.set(p.nodeId, p));
this.loading = false;
this.graphLoaded.emit(graph);
},
error: (err) => {
this.error = err.message || 'Failed to load workflow graph';
this.loading = false;
}
});
}
updateGraph(graph: WorkflowGraph): void {
// Update nodes in place to preserve positions
graph.nodes.forEach(newNode => {
const existing = this.nodes.find(n => n.id === newNode.id);
if (existing) {
Object.assign(existing, newNode);
} else {
this.nodes.push(newNode);
}
});
// Update edges
this.edges = graph.edges;
}
// Event handlers
onNodeClick(node: GraphNode, event: MouseEvent): void {
event.stopPropagation();
this.selectedNodeId = node.id;
this.nodeSelected.emit(node);
}
onNodeDoubleClick(node: GraphNode): void {
this.nodeDoubleClicked.emit(node);
}
onWheel(event: WheelEvent): void {
event.preventDefault();
const delta = event.deltaY > 0 ? -0.1 : 0.1;
const newZoom = Math.max(this.minZoom, Math.min(this.maxZoom, this.zoom + delta));
this.zoom = newZoom;
}
onMouseDown(event: MouseEvent): void {
if (event.button === 0) {
this.isDragging = true;
this.dragStartX = event.clientX - this.panX;
this.dragStartY = event.clientY - this.panY;
}
}
onMouseMove(event: MouseEvent): void {
if (this.isDragging) {
this.panX = event.clientX - this.dragStartX;
this.panY = event.clientY - this.dragStartY;
}
}
onMouseUp(): void {
this.isDragging = false;
}
// Zoom controls
zoomIn(): void {
this.zoom = Math.min(this.maxZoom, this.zoom + 0.25);
}
zoomOut(): void {
this.zoom = Math.max(this.minZoom, this.zoom - 0.25);
}
fitView(): void {
this.zoom = 1;
this.panX = 0;
this.panY = 0;
}
// Layout
onLayoutChange(): void {
this.loadGraph();
}
// Critical path
toggleCriticalPath(): void {
this.showCriticalPath = !this.showCriticalPath;
if (this.showCriticalPath) {
this.loadCriticalPath();
}
}
loadCriticalPath(): void {
this.visualizationService.getCriticalPath(this.runId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (result) => {
this.criticalPathNodes = new Set(result.path);
// Compute edges on critical path
this.criticalPathEdges.clear();
for (let i = 0; i < result.path.length - 1; i++) {
const edgeId = `${result.path[i]}-${result.path[i + 1]}`;
this.criticalPathEdges.add(edgeId);
}
},
error: (err) => console.error('Failed to load critical path:', err)
});
}
toggleTimeline(): void {
this.showTimeline = !this.showTimeline;
}
retry(): void {
this.loadGraph();
}
// Rendering helpers
getNodeTransform(node: GraphNode): string {
const pos = this.positions.get(node.id);
if (pos) {
return `translate(${pos.x - this.nodeWidth / 2}, ${pos.y - this.nodeHeight / 2})`;
}
return 'translate(0, 0)';
}
getEdgePath(edge: GraphEdge): string {
const source = this.positions.get(edge.source);
const target = this.positions.get(edge.target);
if (!source || !target) return '';
const dx = target.x - source.x;
const dy = target.y - source.y;
// Calculate control points for bezier curve
const cx1 = source.x + dx * 0.25;
const cy1 = source.y + dy * 0.1;
const cx2 = target.x - dx * 0.25;
const cy2 = target.y - dy * 0.1;
return `M ${source.x} ${source.y + this.nodeHeight / 2} C ${cx1} ${cy1 + this.nodeHeight}, ${cx2} ${cy2}, ${target.x} ${target.y - this.nodeHeight / 2}`;
}
getEdgeColor(edge: GraphEdge): string {
if (edge.animated) return 'var(--color-running)';
if (this.showCriticalPath && this.criticalPathEdges.has(edge.id)) return 'var(--color-critical)';
return 'var(--color-edge)';
}
getEdgeWidth(edge: GraphEdge): number {
if (this.showCriticalPath && this.criticalPathEdges.has(edge.id)) return 3;
return 2;
}
getNodeFill(node: GraphNode): string {
const colors: Record<string, string> = {
'Pending': 'var(--color-pending-bg)',
'Queued': 'var(--color-queued-bg)',
'Running': 'var(--color-running-bg)',
'Succeeded': 'var(--color-success-bg)',
'Failed': 'var(--color-error-bg)',
'Skipped': 'var(--color-skipped-bg)',
'Cancelled': 'var(--color-cancelled-bg)'
};
return colors[node.status] || 'var(--color-default-bg)';
}
getNodeStroke(node: GraphNode): string {
const colors: Record<string, string> = {
'Pending': 'var(--color-pending-stroke)',
'Queued': 'var(--color-queued-stroke)',
'Running': 'var(--color-running)',
'Succeeded': 'var(--color-success)',
'Failed': 'var(--color-error)',
'Skipped': 'var(--color-skipped-stroke)',
'Cancelled': 'var(--color-cancelled-stroke)'
};
return colors[node.status] || 'var(--color-default-stroke)';
}
getNodeTextColor(node: GraphNode): string {
return node.status === 'failed' || node.status === 'running'
? 'var(--color-text-light)'
: 'var(--color-text)';
}
truncateLabel(label: string): string {
const maxLength = 20;
return label.length > maxLength
? label.substring(0, maxLength - 3) + '...'
: label;
}
formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60000).toFixed(1)}m`;
}
// Minimap helpers
getMinimapX(node: GraphNode): number {
const pos = this.positions.get(node.id);
return pos ? pos.x / 10 : 0;
}
getMinimapY(node: GraphNode): number {
const pos = this.positions.get(node.id);
return pos ? pos.y / 10 : 0;
}
}

View File

@@ -0,0 +1,121 @@
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: AGPL-3.0-or-later
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, BehaviorSubject } from 'rxjs';
export interface DebugSession {
id: string;
sessionId: string;
workflowId: string;
createdAt: string;
expiresAt: string;
status: 'active' | 'paused' | 'completed';
currentSnapshotIndex: number;
totalSnapshots: number;
}
export interface SnapshotSummary {
id: string;
index: number;
timestamp: string;
stepId: string;
stepName: string;
type: 'state-change' | 'input' | 'output' | 'error';
eventType: string;
description: string;
}
export interface SnapshotState {
id: string;
timestamp: string;
stepId: string;
stepName: string;
status: string;
variables: Record<string, unknown>;
inputs: Record<string, unknown>;
outputs: Record<string, unknown>;
logs: string[];
metadata: Record<string, unknown>;
}
@Injectable({
providedIn: 'root'
})
export class TimeTravelService {
private apiBaseUrl = '/api/v1/debug';
private currentSession$ = new BehaviorSubject<DebugSession | null>(null);
private currentSnapshot$ = new BehaviorSubject<SnapshotState | null>(null);
constructor(private http: HttpClient) {}
createSession(workflowId: string): Observable<DebugSession> {
return this.http.post<DebugSession>(`${this.apiBaseUrl}/sessions`, { workflowId });
}
startSession(workflowId: string): Observable<DebugSession> {
return this.createSession(workflowId);
}
getSession(sessionId: string): Observable<DebugSession> {
return this.http.get<DebugSession>(`${this.apiBaseUrl}/sessions/${sessionId}`);
}
endSession(sessionId: string): Observable<void> {
return this.http.delete<void>(`${this.apiBaseUrl}/sessions/${sessionId}`);
}
getCurrentSession(): Observable<DebugSession | null> {
return this.currentSession$.asObservable();
}
setCurrentSession(session: DebugSession | null): void {
this.currentSession$.next(session);
}
getSnapshots(sessionId: string): Observable<SnapshotSummary[]> {
return this.http.get<SnapshotSummary[]>(`${this.apiBaseUrl}/sessions/${sessionId}/snapshots`);
}
getSnapshotState(sessionId: string, snapshotId: string): Observable<SnapshotState> {
return this.http.get<SnapshotState>(`${this.apiBaseUrl}/sessions/${sessionId}/snapshots/${snapshotId}`);
}
navigateToSnapshot(sessionId: string, snapshotIndex: number): Observable<SnapshotState> {
return this.http.post<SnapshotState>(`${this.apiBaseUrl}/sessions/${sessionId}/navigate`, {
snapshotIndex
});
}
jumpToSnapshot(sessionId: string, snapshotIndex: number): Observable<SnapshotState> {
return this.navigateToSnapshot(sessionId, snapshotIndex);
}
stepForward(sessionId: string): Observable<SnapshotState> {
return this.http.post<SnapshotState>(`${this.apiBaseUrl}/sessions/${sessionId}/step-forward`, {});
}
stepBackward(sessionId: string): Observable<SnapshotState> {
return this.http.post<SnapshotState>(`${this.apiBaseUrl}/sessions/${sessionId}/step-backward`, {});
}
playForward(sessionId: string, speed: number = 1): Observable<void> {
return this.http.post<void>(`${this.apiBaseUrl}/sessions/${sessionId}/play`, { speed });
}
pause(sessionId: string): Observable<void> {
return this.http.post<void>(`${this.apiBaseUrl}/sessions/${sessionId}/pause`, {});
}
jumpToStart(sessionId: string): Observable<SnapshotState> {
return this.navigateToSnapshot(sessionId, 0);
}
jumpToEnd(sessionId: string): Observable<SnapshotState> {
const session = this.currentSession$.value;
if (session) {
return this.navigateToSnapshot(sessionId, session.totalSnapshots - 1);
}
throw new Error('No active session');
}
}

View File

@@ -0,0 +1,140 @@
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: AGPL-3.0-or-later
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, BehaviorSubject, map } from 'rxjs';
export interface GraphNode {
id: string;
label: string;
type: 'task' | 'gate' | 'approval' | 'script';
status: 'pending' | 'running' | 'succeeded' | 'failed' | 'skipped' | 'cancelled';
startTime?: string;
endTime?: string;
duration?: number;
metadata?: Record<string, unknown>;
}
export interface GraphEdge {
id: string;
source: string;
target: string;
type: 'dependency' | 'trigger' | 'conditional';
label?: string;
animated?: boolean;
}
export interface NodePosition {
nodeId: string;
x: number;
y: number;
}
export interface WorkflowGraph {
id: string;
name: string;
nodes: GraphNode[];
edges: GraphEdge[];
positions: NodePosition[];
layoutAlgorithm: 'dagre' | 'elk' | 'force';
metadata?: Record<string, unknown>;
}
export interface StepDetails {
nodeId: string;
name: string;
description?: string;
status: string;
startTime?: string;
endTime?: string;
duration?: number;
inputs?: Record<string, unknown>;
outputs?: Record<string, unknown>;
logs?: string[];
artifacts?: string[];
error?: {
message: string;
stackTrace?: string;
};
}
export interface CriticalPathResult {
path: string[];
totalDuration: number;
}
export interface LogQueryParams {
offset?: number;
limit?: number;
filter?: string;
level?: string;
search?: string;
}
export interface LogResult {
logs: string[];
totalCount: number;
hasMore: boolean;
}
@Injectable({
providedIn: 'root'
})
export class WorkflowVisualizationService {
private apiBaseUrl = '/api/v1/workflows';
private currentGraph$ = new BehaviorSubject<WorkflowGraph | null>(null);
constructor(private http: HttpClient) {}
getWorkflowGraph(workflowId: string): Observable<WorkflowGraph> {
return this.http.get<WorkflowGraph>(`${this.apiBaseUrl}/${workflowId}/graph`);
}
getGraph(runId: string): Observable<WorkflowGraph> {
return this.getWorkflowGraph(runId);
}
getLayoutedGraph(runId: string, layoutAlgorithm: string): Observable<WorkflowGraph> {
return this.http.get<WorkflowGraph>(`${this.apiBaseUrl}/${runId}/graph?layout=${layoutAlgorithm}`);
}
getCurrentGraph(): Observable<WorkflowGraph | null> {
return this.currentGraph$.asObservable();
}
setCurrentGraph(graph: WorkflowGraph): void {
this.currentGraph$.next(graph);
}
getStepDetails(workflowId: string, stepId: string): Observable<StepDetails> {
return this.http.get<StepDetails>(`${this.apiBaseUrl}/${workflowId}/steps/${stepId}`);
}
getStepLogs(workflowId: string, stepId: string, params: LogQueryParams): Observable<LogResult> {
const queryParams = new URLSearchParams();
if (params.offset !== undefined) queryParams.set('offset', params.offset.toString());
if (params.limit !== undefined) queryParams.set('limit', params.limit.toString());
if (params.filter) queryParams.set('filter', params.filter);
return this.http.get<LogResult>(`${this.apiBaseUrl}/${workflowId}/steps/${stepId}/logs?${queryParams}`);
}
getCriticalPath(workflowId: string): Observable<CriticalPathResult> {
return this.http.get<CriticalPathResult>(`${this.apiBaseUrl}/${workflowId}/critical-path`);
}
saveNodePositions(workflowId: string, positions: NodePosition[]): Observable<void> {
return this.http.put<void>(`${this.apiBaseUrl}/${workflowId}/positions`, { positions });
}
retryStep(workflowId: string, stepId: string): Observable<{ success: boolean }> {
return this.http.post<{ success: boolean }>(`${this.apiBaseUrl}/${workflowId}/steps/${stepId}/retry`, {});
}
skipStep(workflowId: string, stepId: string): Observable<{ success: boolean }> {
return this.http.post<{ success: boolean }>(`${this.apiBaseUrl}/${workflowId}/steps/${stepId}/skip`, {});
}
cancelWorkflow(workflowId: string): Observable<{ success: boolean }> {
return this.http.post<{ success: boolean }>(`${this.apiBaseUrl}/${workflowId}/cancel`, {});
}
}