feat(T4.1-T4.4): implement advanced operations & analytics (part 1)
T4.1: Real-time Metrics Dashboard - Add internal/dashboard package with MetricsAggregator - Record, aggregate, and query metrics - Percentile calculations (p50, p95, p99) - Time-series data with max size eviction - 13 metrics tests, all passing T4.2: Workflow Visualization & DAG Rendering - Add internal/visualization package with DAGRenderer - Convert dependency graphs to DOT format - Critical path highlighting - Topological sorting with parallel task detection - HTML rendering for visualization - 11 DAG rendering tests, all passing T4.3: Advanced Search & Filtering - Add internal/search package with WorkflowSearch - Full-text indexing with word-based lookup - Filter by status, assignee, tag, date range - Regex pattern matching - Saved filters for reusable queries - 15 search tests, all passing T4.4: Cost Tracking & Optimization - Add internal/cost package with CostTracker - Track LLM API costs (by token) - Track git operation costs - Track compute resource costs (by duration) - Cost aggregation by workflow/type - Optimization recommendations - 11 cost tests, all passing Total T4.1-T4.4: 50 tests passing Next: T4.5-T4.8 (alerting, profiling, multi-cluster, self-deployment)
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
package visualization
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TaskNode represents a task in the DAG
|
||||
type TaskNode struct {
|
||||
ID string
|
||||
Status string // pending, running, completed, failed
|
||||
Duration float64
|
||||
Critical bool
|
||||
}
|
||||
|
||||
// DAGRenderer renders workflow dependency graphs
|
||||
type DAGRenderer struct {
|
||||
nodes map[string]*TaskNode
|
||||
edges map[string][]string
|
||||
}
|
||||
|
||||
// NewDAGRenderer creates a new DAG renderer
|
||||
func NewDAGRenderer() *DAGRenderer {
|
||||
return &DAGRenderer{
|
||||
nodes: make(map[string]*TaskNode),
|
||||
edges: make(map[string][]string),
|
||||
}
|
||||
}
|
||||
|
||||
// AddNode adds a task node
|
||||
func (dr *DAGRenderer) AddNode(id, status string, duration float64) {
|
||||
dr.nodes[id] = &TaskNode{
|
||||
ID: id,
|
||||
Status: status,
|
||||
Duration: duration,
|
||||
}
|
||||
}
|
||||
|
||||
// AddEdge adds a dependency edge
|
||||
func (dr *DAGRenderer) AddEdge(from, to string) error {
|
||||
if _, exists := dr.nodes[from]; !exists {
|
||||
return fmt.Errorf("source node not found: %s", from)
|
||||
}
|
||||
if _, exists := dr.nodes[to]; !exists {
|
||||
return fmt.Errorf("target node not found: %s", to)
|
||||
}
|
||||
|
||||
dr.edges[from] = append(dr.edges[from], to)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkCriticalPath marks nodes on the critical path
|
||||
func (dr *DAGRenderer) MarkCriticalPath(nodes []string) error {
|
||||
for _, nodeID := range nodes {
|
||||
if node, exists := dr.nodes[nodeID]; exists {
|
||||
node.Critical = true
|
||||
} else {
|
||||
return fmt.Errorf("node not found: %s", nodeID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderDOT generates DOT format for Graphviz
|
||||
func (dr *DAGRenderer) RenderDOT() string {
|
||||
var buf strings.Builder
|
||||
|
||||
buf.WriteString("digraph WorkflowDAG {\n")
|
||||
buf.WriteString(" rankdir=LR;\n")
|
||||
buf.WriteString(" node [shape=box];\n\n")
|
||||
|
||||
// Render nodes
|
||||
for _, node := range dr.nodes {
|
||||
color := "lightgray"
|
||||
if node.Critical {
|
||||
color = "red"
|
||||
} else if node.Status == "completed" {
|
||||
color = "lightgreen"
|
||||
} else if node.Status == "failed" {
|
||||
color = "lightcoral"
|
||||
} else if node.Status == "running" {
|
||||
color = "lightyellow"
|
||||
}
|
||||
|
||||
label := fmt.Sprintf("%s\\n%.0fms", node.ID, node.Duration)
|
||||
buf.WriteString(fmt.Sprintf(" \"%s\" [label=\"%s\", fillcolor=%s, style=filled];\n",
|
||||
node.ID, label, color))
|
||||
}
|
||||
|
||||
buf.WriteString("\n")
|
||||
|
||||
// Render edges
|
||||
for from, tos := range dr.edges {
|
||||
for _, to := range tos {
|
||||
buf.WriteString(fmt.Sprintf(" \"%s\" -> \"%s\";\n", from, to))
|
||||
}
|
||||
}
|
||||
|
||||
buf.WriteString("}\n")
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// RenderHTML generates a simple HTML visualization
|
||||
func (dr *DAGRenderer) RenderHTML() string {
|
||||
var buf strings.Builder
|
||||
|
||||
buf.WriteString("<html><body>\n")
|
||||
buf.WriteString("<h1>Workflow DAG</h1>\n")
|
||||
buf.WriteString("<table border='1'>\n")
|
||||
buf.WriteString("<tr><th>Task ID</th><th>Status</th><th>Duration (ms)</th><th>Critical Path</th></tr>\n")
|
||||
|
||||
for _, node := range dr.nodes {
|
||||
critical := "No"
|
||||
if node.Critical {
|
||||
critical = "Yes"
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("<tr><td>%s</td><td>%s</td><td>%.0f</td><td>%s</td></tr>\n",
|
||||
node.ID, node.Status, node.Duration, critical))
|
||||
}
|
||||
|
||||
buf.WriteString("</table>\n")
|
||||
buf.WriteString("</body></html>\n")
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// GetTopologicalSort returns tasks in topological order
|
||||
func (dr *DAGRenderer) GetTopologicalSort() ([]string, error) {
|
||||
// Simple topological sort using DFS
|
||||
visited := make(map[string]bool)
|
||||
result := make([]string, 0)
|
||||
|
||||
var visit func(string) error
|
||||
visit = func(nodeID string) error {
|
||||
if visited[nodeID] {
|
||||
return nil
|
||||
}
|
||||
visited[nodeID] = true
|
||||
|
||||
// Visit dependencies first
|
||||
for _, dep := range dr.edges[nodeID] {
|
||||
if err := visit(dep); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, nodeID)
|
||||
return nil
|
||||
}
|
||||
|
||||
for nodeID := range dr.nodes {
|
||||
if err := visit(nodeID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetParallel returns groups of tasks that can run in parallel
|
||||
func (dr *DAGRenderer) GetParallel() map[int][]string {
|
||||
levels := make(map[int][]string)
|
||||
inDegree := make(map[string]int)
|
||||
|
||||
// Calculate in-degree
|
||||
for _, node := range dr.nodes {
|
||||
inDegree[node.ID] = 0
|
||||
}
|
||||
|
||||
for _, tos := range dr.edges {
|
||||
for _, to := range tos {
|
||||
inDegree[to]++
|
||||
}
|
||||
}
|
||||
|
||||
// Find nodes by level
|
||||
processed := make(map[string]bool)
|
||||
level := 0
|
||||
|
||||
for len(processed) < len(dr.nodes) {
|
||||
var current []string
|
||||
|
||||
for _, node := range dr.nodes {
|
||||
if !processed[node.ID] && inDegree[node.ID] == 0 {
|
||||
current = append(current, node.ID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(current) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
levels[level] = current
|
||||
|
||||
// Update in-degrees
|
||||
for _, nodeID := range current {
|
||||
processed[nodeID] = true
|
||||
for _, to := range dr.edges[nodeID] {
|
||||
inDegree[to]--
|
||||
}
|
||||
}
|
||||
|
||||
level++
|
||||
}
|
||||
|
||||
return levels
|
||||
}
|
||||
|
||||
// GetStats returns statistics about the DAG
|
||||
func (dr *DAGRenderer) GetStats() map[string]interface{} {
|
||||
totalDuration := 0.0
|
||||
maxDuration := 0.0
|
||||
criticalCount := 0
|
||||
edgeCount := 0
|
||||
|
||||
for _, node := range dr.nodes {
|
||||
totalDuration += node.Duration
|
||||
if node.Duration > maxDuration {
|
||||
maxDuration = node.Duration
|
||||
}
|
||||
if node.Critical {
|
||||
criticalCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Count total edges
|
||||
for _, tos := range dr.edges {
|
||||
edgeCount += len(tos)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"node_count": len(dr.nodes),
|
||||
"edge_count": edgeCount,
|
||||
"total_duration": totalDuration,
|
||||
"max_duration": maxDuration,
|
||||
"critical_count": criticalCount,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package visualization
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAddNode(t *testing.T) {
|
||||
dag := NewDAGRenderer()
|
||||
dag.AddNode("T1", "completed", 100)
|
||||
|
||||
assert.NotNil(t, dag.nodes["T1"])
|
||||
assert.Equal(t, "completed", dag.nodes["T1"].Status)
|
||||
}
|
||||
|
||||
func TestAddEdge(t *testing.T) {
|
||||
dag := NewDAGRenderer()
|
||||
dag.AddNode("T1", "completed", 100)
|
||||
dag.AddNode("T2", "completed", 200)
|
||||
|
||||
err := dag.AddEdge("T1", "T2")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(dag.edges["T1"]))
|
||||
}
|
||||
|
||||
func TestAddEdgeNotFound(t *testing.T) {
|
||||
dag := NewDAGRenderer()
|
||||
dag.AddNode("T1", "completed", 100)
|
||||
|
||||
err := dag.AddEdge("T1", "T2")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestMarkCriticalPath(t *testing.T) {
|
||||
dag := NewDAGRenderer()
|
||||
dag.AddNode("T1", "completed", 100)
|
||||
dag.AddNode("T2", "completed", 200)
|
||||
|
||||
err := dag.MarkCriticalPath([]string{"T1", "T2"})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, dag.nodes["T1"].Critical)
|
||||
assert.True(t, dag.nodes["T2"].Critical)
|
||||
}
|
||||
|
||||
func TestRenderDOT(t *testing.T) {
|
||||
dag := NewDAGRenderer()
|
||||
dag.AddNode("T1", "completed", 100)
|
||||
dag.AddNode("T2", "running", 200)
|
||||
dag.AddEdge("T1", "T2")
|
||||
|
||||
dot := dag.RenderDOT()
|
||||
assert.True(t, strings.Contains(dot, "digraph WorkflowDAG"))
|
||||
assert.True(t, strings.Contains(dot, "T1"))
|
||||
assert.True(t, strings.Contains(dot, "T2"))
|
||||
assert.True(t, strings.Contains(dot, "->"))
|
||||
}
|
||||
|
||||
func TestRenderHTML(t *testing.T) {
|
||||
dag := NewDAGRenderer()
|
||||
dag.AddNode("T1", "completed", 100)
|
||||
|
||||
html := dag.RenderHTML()
|
||||
assert.True(t, strings.Contains(html, "<html>"))
|
||||
assert.True(t, strings.Contains(html, "Workflow DAG"))
|
||||
assert.True(t, strings.Contains(html, "T1"))
|
||||
}
|
||||
|
||||
func TestGetTopologicalSort(t *testing.T) {
|
||||
dag := NewDAGRenderer()
|
||||
dag.AddNode("T1", "completed", 100)
|
||||
dag.AddNode("T2", "completed", 200)
|
||||
dag.AddNode("T3", "completed", 150)
|
||||
|
||||
dag.AddEdge("T1", "T2")
|
||||
dag.AddEdge("T2", "T3")
|
||||
|
||||
sorted, err := dag.GetTopologicalSort()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 3, len(sorted))
|
||||
}
|
||||
|
||||
func TestGetParallel(t *testing.T) {
|
||||
dag := NewDAGRenderer()
|
||||
dag.AddNode("T1", "completed", 100)
|
||||
dag.AddNode("T2", "completed", 200)
|
||||
dag.AddNode("T3", "completed", 150)
|
||||
|
||||
dag.AddEdge("T1", "T3")
|
||||
|
||||
parallel := dag.GetParallel()
|
||||
assert.True(t, len(parallel) > 0)
|
||||
}
|
||||
|
||||
func TestGetStats(t *testing.T) {
|
||||
dag := NewDAGRenderer()
|
||||
dag.AddNode("T1", "completed", 100)
|
||||
dag.AddNode("T2", "completed", 200)
|
||||
dag.AddEdge("T1", "T2")
|
||||
|
||||
stats := dag.GetStats()
|
||||
assert.Equal(t, 2, stats["node_count"])
|
||||
assert.Equal(t, 1, stats["edge_count"])
|
||||
assert.Equal(t, 300.0, stats["total_duration"])
|
||||
}
|
||||
|
||||
func TestCriticalPathHighlighting(t *testing.T) {
|
||||
dag := NewDAGRenderer()
|
||||
dag.AddNode("T1", "completed", 100)
|
||||
dag.AddNode("T2", "completed", 200)
|
||||
dag.MarkCriticalPath([]string{"T1", "T2"})
|
||||
|
||||
dot := dag.RenderDOT()
|
||||
assert.True(t, strings.Contains(dot, "fillcolor=red"))
|
||||
}
|
||||
|
||||
func TestComplexDAG(t *testing.T) {
|
||||
dag := NewDAGRenderer()
|
||||
|
||||
// Create a diamond-shaped DAG
|
||||
dag.AddNode("Start", "completed", 50)
|
||||
dag.AddNode("A", "completed", 100)
|
||||
dag.AddNode("B", "completed", 150)
|
||||
dag.AddNode("End", "completed", 50)
|
||||
|
||||
dag.AddEdge("Start", "A")
|
||||
dag.AddEdge("Start", "B")
|
||||
dag.AddEdge("A", "End")
|
||||
dag.AddEdge("B", "End")
|
||||
|
||||
stats := dag.GetStats()
|
||||
assert.Equal(t, 4, stats["node_count"])
|
||||
assert.Equal(t, 4, stats["edge_count"])
|
||||
}
|
||||
Reference in New Issue
Block a user