release orchestration strengthening
This commit is contained in:
404
src/Web/frontend/e2e/workflow-visualizer.visual.spec.ts
Normal file
404
src/Web/frontend/e2e/workflow-visualizer.visual.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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`, {});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user