diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 95a7ec8..43797a7 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -35,3 +35,53 @@ jobs: with: name: mariner-rpm path: bin/RPMS + + performance-benchmark: + runs-on: ubuntu-latest + needs: build + steps: + - uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.20' + - name: Install dependencies for benchmarking + run: | + sudo apt-get update + sudo apt-get install -y jq bc + - name: Run performance benchmarks + run: | + # Set up environment + mkdir -p bin perf-results + export AZTUI_CONFIG_PATH=$PWD/conf/default.yaml + export PERF_ITERATIONS=3 + + # Run the performance benchmark script + ./scripts/perf-benchmark.sh + env: + AZTUI_CONFIG_PATH: ${{ github.workspace }}/conf/default.yaml + PERF_ITERATIONS: 3 + - name: Upload performance benchmark results + uses: actions/upload-artifact@v4 + with: + name: performance-benchmarks + path: perf-results/ + - name: Display performance summary + run: | + echo "=== Performance Benchmark Summary ===" + if [ -d "perf-results" ]; then + # Find the latest comparison file + COMPARISON_FILE=$(find perf-results -name "comparison_*.json" | sort | tail -1) + if [ -f "$COMPARISON_FILE" ]; then + echo "📊 Performance Results:" + echo "Cache Hit Ratio: $(jq -r '.cache_statistics.hit_ratio * 100 | floor')%" + echo "Average Speedup: $(jq -r '.improvements.average_speedup')x" + echo "Total Speedup: $(jq -r '.improvements.total_speedup')x" + echo "" + echo "📈 Detailed metrics available in artifacts" + else + echo "⚠️ No comparison results found" + fi + else + echo "⚠️ No benchmark results directory found" + fi diff --git a/.gitignore b/.gitignore index 32b7748..9210470 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /bin/* /rpmbuild/* +/perf-results/* aztui.log +*.prof diff --git a/conf/default.yaml b/conf/default.yaml index 54f977b..7f47c29 100644 --- a/conf/default.yaml +++ b/conf/default.yaml @@ -1,3 +1,6 @@ +cache: + ttlSeconds: 300 # 5 minutes cache TTL + views: - view: "SubscriptionListView" actions: diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md new file mode 100644 index 0000000..b59dccb --- /dev/null +++ b/docs/PERFORMANCE.md @@ -0,0 +1,81 @@ +# Performance Benchmarking + +This directory contains tools for benchmarking the performance improvements achieved by the caching system in aztui. + +## Files + +- `cmd/perfbench/main.go` - Performance benchmark tool that can run with or without caching +- `scripts/perf-benchmark.sh` - Script that runs benchmarks with both configurations and compares results + +## Usage + +### Manual Benchmarking + +Run the benchmark tool directly: + +```bash +# With caching enabled (default) +cd src && go run cmd/perfbench/main.go -iterations=5 -verbose + +# Without caching +cd src && go run cmd/perfbench/main.go -with-cache=false -iterations=5 -verbose + +# JSON output for automated analysis +cd src && go run cmd/perfbench/main.go -json > results.json +``` + +### Automated Benchmarking + +Run the complete benchmark script: + +```bash +# Use default settings (5 iterations) +./scripts/perf-benchmark.sh + +# Custom iteration count +PERF_ITERATIONS=10 ./scripts/perf-benchmark.sh +``` + +This will: +1. Build the benchmark tool +2. Run benchmarks without caching +3. Run benchmarks with caching +4. Generate a comparison report +5. Save all results to `perf-results/` + +### CI Integration + +The benchmarks are automatically run in the GitHub Actions CI pipeline as part of the `performance-benchmark` job. Results are uploaded as artifacts and a summary is displayed in the CI logs. + +## Output + +The benchmark generates: +- Individual result files for each run mode (`with-cache_*.json`, `without-cache_*.json`) +- A comparison file (`comparison_*.json`) showing speedup metrics +- Console output with summary statistics + +### Example Results + +``` +=== Performance Comparison Summary === +Cache hit ratio: 62% +Average operation speedup: 1.14x +Total execution speedup: 1.14x +Cached average: 105334265 ns +Uncached average: 120503898 ns +``` + +## Configuration + +Benchmarks use the same configuration as the main application (`conf/default.yaml`). The cache TTL and other settings will affect the results. + +Environment variables: +- `PERF_ITERATIONS` - Number of iterations per benchmark (default: 5) +- `AZTUI_CONFIG_PATH` - Path to configuration file + +## Dependencies + +The benchmark script requires: +- `jq` - JSON processing +- `bc` - Mathematical calculations +- Go 1.20+ - Building and running the benchmark tool \ No newline at end of file diff --git a/scripts/perf-benchmark.sh b/scripts/perf-benchmark.sh new file mode 100755 index 0000000..e22f2f4 --- /dev/null +++ b/scripts/perf-benchmark.sh @@ -0,0 +1,173 @@ +#!/bin/bash + +# Performance benchmark script for CI pipeline +# Runs aztui performance tests with and without caching to show improvements + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +AZTUI_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +SRC_DIR="${AZTUI_ROOT}/src" +PERFBENCH_BIN="${AZTUI_ROOT}/bin/perfbench" +CONFIG_PATH="${AZTUI_ROOT}/conf/default.yaml" + +# Configuration +ITERATIONS=${PERF_ITERATIONS:-5} +OUTPUT_DIR="${AZTUI_ROOT}/perf-results" +TIMESTAMP=$(date +"%Y%m%d_%H%M%S") + +echo "=== Azure TUI Performance Benchmark ===" +echo "Timestamp: $(date)" +echo "Iterations per test: ${ITERATIONS}" +echo "Config: ${CONFIG_PATH}" +echo "Output directory: ${OUTPUT_DIR}" +echo + +# Create output directory +mkdir -p "${OUTPUT_DIR}" + +# Build the performance benchmark tool +echo "Building performance benchmark tool..." +cd "${SRC_DIR}" +go build -o "${PERFBENCH_BIN}" cmd/perfbench/main.go +echo "✓ Built perfbench tool at ${PERFBENCH_BIN}" +echo + +# Function to run benchmark and save results +run_benchmark() { + local mode="$1" + local cache_flag="$2" + local output_file="${OUTPUT_DIR}/${mode}_${TIMESTAMP}.json" + + echo "Running benchmark: ${mode}" + echo "Command: ${PERFBENCH_BIN} ${cache_flag} -iterations ${ITERATIONS} -json -config ${CONFIG_PATH}" + + # Set environment for Azure CLI (in case it's needed) + export AZTUI_CONFIG_PATH="${CONFIG_PATH}" + + # Run the benchmark + if "${PERFBENCH_BIN}" ${cache_flag} -iterations "${ITERATIONS}" -json -config "${CONFIG_PATH}" > "${output_file}"; then + echo "✓ ${mode} benchmark completed" + echo " Results saved to: ${output_file}" + + # Extract key metrics for quick summary + local total_ops=$(jq -r '.total_operations' "${output_file}") + local avg_duration=$(jq -r '.average_duration_ns' "${output_file}") + local cache_hits=$(jq -r '.cache_hits' "${output_file}") + local cache_misses=$(jq -r '.cache_misses' "${output_file}") + local hit_ratio=$(jq -r '.cache_hit_ratio' "${output_file}") + + echo " Total operations: ${total_ops}" + echo " Average duration: ${avg_duration} ns" + echo " Cache hits: ${cache_hits}" + echo " Cache misses: ${cache_misses}" + echo " Cache hit ratio: $(echo "${hit_ratio} * 100" | bc -l | cut -d. -f1)%" + else + echo "✗ ${mode} benchmark failed" + return 1 + fi + echo +} + +# Function to compare results +compare_results() { + local cached_file="${OUTPUT_DIR}/with-cache_${TIMESTAMP}.json" + local uncached_file="${OUTPUT_DIR}/without-cache_${TIMESTAMP}.json" + local comparison_file="${OUTPUT_DIR}/comparison_${TIMESTAMP}.json" + + if [[ -f "${cached_file}" && -f "${uncached_file}" ]]; then + echo "Generating performance comparison..." + + # Extract metrics using jq + local cached_avg=$(jq -r '.average_duration_ns' "${cached_file}") + local uncached_avg=$(jq -r '.average_duration_ns' "${uncached_file}") + local cached_total=$(jq -r '.total_duration_ns' "${cached_file}") + local uncached_total=$(jq -r '.total_duration_ns' "${uncached_file}") + local cache_hits=$(jq -r '.cache_hits' "${cached_file}") + local cache_misses=$(jq -r '.cache_misses' "${cached_file}") + local hit_ratio=$(jq -r '.cache_hit_ratio' "${cached_file}") + + # Calculate improvement ratios (handle division by zero) + local avg_improvement="N/A" + local total_improvement="N/A" + if [[ "${cached_avg}" != "0" && "${cached_avg}" != "null" ]]; then + avg_improvement=$(echo "scale=2; ${uncached_avg} / ${cached_avg}" | bc -l) + fi + if [[ "${cached_total}" != "0" && "${cached_total}" != "null" ]]; then + total_improvement=$(echo "scale=2; ${uncached_total} / ${cached_total}" | bc -l) + fi + + # Create comparison JSON + jq -n \ + --arg timestamp "$(date -Iseconds)" \ + --argjson cached_avg "${cached_avg}" \ + --argjson uncached_avg "${uncached_avg}" \ + --argjson cached_total "${cached_total}" \ + --argjson uncached_total "${uncached_total}" \ + --argjson cache_hits "${cache_hits}" \ + --argjson cache_misses "${cache_misses}" \ + --argjson hit_ratio "${hit_ratio}" \ + --arg avg_improvement "${avg_improvement}" \ + --arg total_improvement "${total_improvement}" \ + '{ + timestamp: $timestamp, + cached_performance: { + average_duration_ns: $cached_avg, + total_duration_ns: $cached_total + }, + uncached_performance: { + average_duration_ns: $uncached_avg, + total_duration_ns: $uncached_total + }, + cache_statistics: { + hits: $cache_hits, + misses: $cache_misses, + hit_ratio: $hit_ratio + }, + improvements: { + average_speedup: $avg_improvement, + total_speedup: $total_improvement + } + }' > "${comparison_file}" + + echo "✓ Comparison saved to: ${comparison_file}" + echo + echo "=== Performance Comparison Summary ===" + echo "Cache hit ratio: $(echo "${hit_ratio} * 100" | bc -l | cut -d. -f1)%" + echo "Average operation speedup: ${avg_improvement}x" + echo "Total execution speedup: ${total_improvement}x" + echo "Cached average: ${cached_avg} ns" + echo "Uncached average: ${uncached_avg} ns" + echo + else + echo "⚠ Cannot generate comparison - missing result files" + fi +} + +# Check dependencies +echo "Checking dependencies..." +command -v jq >/dev/null 2>&1 || { echo "✗ jq is required but not installed. Please install jq."; exit 1; } +command -v bc >/dev/null 2>&1 || { echo "✗ bc is required but not installed. Please install bc."; exit 1; } +echo "✓ All dependencies satisfied" +echo + +# Run benchmarks +echo "Starting performance benchmarks..." +echo "============================================" + +# Run without cache first (to ensure no cached data affects the test) +run_benchmark "without-cache" "-with-cache=false" + +# Run with cache +run_benchmark "with-cache" "-with-cache=true" + +# Compare results +compare_results + +echo "============================================" +echo "Performance benchmark completed!" +echo "Results available in: ${OUTPUT_DIR}" +echo + +# Exit with success if we got this far +exit 0 \ No newline at end of file diff --git a/src/cmd/main.go b/src/cmd/main.go index 4c037de..aad76b5 100644 --- a/src/cmd/main.go +++ b/src/cmd/main.go @@ -1,15 +1,18 @@ package main import ( - _ "fmt" - + "flag" + "fmt" "os" - _ "strings" + "os/signal" + "syscall" _ "github.com/brendank310/aztui/pkg/azcli" + "github.com/brendank310/aztui/pkg/cache" "github.com/brendank310/aztui/pkg/config" "github.com/brendank310/aztui/pkg/logger" "github.com/brendank310/aztui/pkg/resourceviews" + "github.com/brendank310/aztui/pkg/tracing" "github.com/gdamore/tcell/v2" _ "github.com/rivo/tview" @@ -19,6 +22,7 @@ type AzTuiState struct { // Basic TUI variables *resourceviews.AppLayout config.Config + CacheService *cache.ResourceCacheService } func NewAzTuiState() *AzTuiState { @@ -38,11 +42,18 @@ func NewAzTuiState() *AzTuiState { panic(err) } + // Initialize cache service with configured TTL + cacheService := cache.NewResourceCacheService(c.GetCacheTTL()) + a := AzTuiState{ - AppLayout: resourceviews.NewAppLayout(), - Config: c, + AppLayout: resourceviews.NewAppLayout(), + Config: c, + CacheService: cacheService, } + // Initialize cache service globally for views to use + resourceviews.SetCacheService(cacheService) + subscriptionList := resourceviews.NewSubscriptionListView(a.AppLayout) if subscriptionList == nil { panic("unable to create a subscription list") @@ -60,9 +71,69 @@ func NewAzTuiState() *AzTuiState { } func main() { + // Parse command line flags + var ( + perfTrace = flag.Bool("perf-trace", false, "Enable performance tracing and output statistics to stderr") + perfCPUProfile = flag.String("perf-cpu-profile", "", "Enable CPU profiling and write to specified file (e.g., cpu.prof)") + perfMaxTraces = flag.Int("perf-max-traces", 1000, "Maximum number of operation traces to keep in memory") + showVersion = flag.Bool("version", false, "Show version and exit") + ) + flag.Parse() + + if *showVersion { + fmt.Println("aztui version 0.0.1") + return + } + + // Initialize performance tracing if requested + if *perfTrace { + tracing.InitTracer(true, *perfMaxTraces) + tracer := tracing.GetTracer() + + // Start CPU profiling if requested + if *perfCPUProfile != "" { + cpuProfilePath := *perfCPUProfile + if err := tracer.StartCPUProfile(cpuProfilePath); err != nil { + fmt.Fprintf(os.Stderr, "Error starting CPU profile: %v\n", err) + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "CPU profiling enabled, writing to %s\n", cpuProfilePath) + } + + // Set up signal handler to output stats on exit + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + go func() { + <-c + fmt.Fprintf(os.Stderr, "\n=== Performance Statistics ===\n") + tracer.OutputStats() + tracer.StopCPUProfile() + os.Exit(0) + }() + + fmt.Fprintf(os.Stderr, "Performance tracing enabled (max traces: %d)\n", *perfMaxTraces) + } else { + tracing.InitTracer(false, 1000) + } + a := NewAzTuiState() if err := a.AppLayout.App.SetRoot(a.AppLayout.Grid, true).Run(); err != nil { + // Output performance stats before exiting on error + if *perfTrace { + tracer := tracing.GetTracer() + fmt.Fprintf(os.Stderr, "\n=== Performance Statistics (Error Exit) ===\n") + tracer.OutputStats() + tracer.StopCPUProfile() + } panic(err) } + + // Output performance stats on normal exit + if *perfTrace { + tracer := tracing.GetTracer() + fmt.Fprintf(os.Stderr, "\n=== Performance Statistics ===\n") + tracer.OutputStats() + tracer.StopCPUProfile() + } } diff --git a/src/cmd/perfbench/main.go b/src/cmd/perfbench/main.go new file mode 100644 index 0000000..e952d59 --- /dev/null +++ b/src/cmd/perfbench/main.go @@ -0,0 +1,249 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "time" + + "github.com/brendank310/aztui/pkg/cache" + "github.com/brendank310/aztui/pkg/config" + "github.com/brendank310/aztui/pkg/logger" + "github.com/brendank310/aztui/pkg/resourceviews" + "github.com/brendank310/aztui/pkg/tracing" +) + +type BenchmarkResult struct { + Mode string `json:"mode"` + TotalOperations int `json:"total_operations"` + TotalDuration time.Duration `json:"total_duration_ns"` + AverageDuration time.Duration `json:"average_duration_ns"` + CacheHits int `json:"cache_hits"` + CacheMisses int `json:"cache_misses"` + CacheHitRatio float64 `json:"cache_hit_ratio"` + OperationDetails []OperationResult `json:"operation_details"` + TracingStats map[string]interface{} `json:"tracing_stats,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +type OperationResult struct { + Operation string `json:"operation"` + Duration time.Duration `json:"duration_ns"` + CacheHit bool `json:"cache_hit"` + Error string `json:"error,omitempty"` +} + +func main() { + var ( + withCache = flag.Bool("with-cache", true, "Enable caching for the benchmark") + iterations = flag.Int("iterations", 3, "Number of iterations to run for each operation") + outputJSON = flag.Bool("json", false, "Output results in JSON format") + configPath = flag.String("config", "", "Path to config file (defaults to AZTUI_CONFIG_PATH env var)") + verbose = flag.Bool("verbose", false, "Enable verbose output") + ) + flag.Parse() + + // Initialize logger + err := logger.InitLogger() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err) + os.Exit(1) + } + + // Load configuration + if *configPath == "" { + *configPath = os.Getenv("AZTUI_CONFIG_PATH") + if *configPath == "" { + *configPath = os.Getenv("HOME") + "/.config/aztui.yaml" + } + } + + c, err := config.LoadConfig(*configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err) + os.Exit(1) + } + + // Initialize tracing for performance measurement + tracing.InitTracer(true, 1000) + tracer := tracing.GetTracer() + + // Initialize cache service + var cacheService *cache.ResourceCacheService + mode := "without-cache" + if *withCache { + mode = "with-cache" + cacheService = cache.NewResourceCacheService(c.GetCacheTTL()) + resourceviews.SetCacheService(cacheService) + if *verbose { + fmt.Fprintf(os.Stderr, "Cache enabled with TTL: %v\n", c.GetCacheTTL()) + } + } else { + // Set cache service to nil to disable caching + resourceviews.SetCacheService(nil) + if *verbose { + fmt.Fprintf(os.Stderr, "Cache disabled\n") + } + } + + // Run benchmark operations + result := BenchmarkResult{ + Mode: mode, + Timestamp: time.Now(), + } + + operations := []struct { + name string + fn func() error + }{ + {"cache-operations", func() error { + // Simulate cache operations without requiring Azure authentication + if cacheService != nil { + // Test cache operations + testKey := "test-subscription-key" + testData := []string{"subscription1", "subscription2", "subscription3"} + + // First operation should be a cache miss + _, err := tracer.TraceFunc("cache-operation", testKey, func() (interface{}, bool, error) { + result, err := cacheService.GetOrFetch(testKey, func() (interface{}, error) { + time.Sleep(10 * time.Millisecond) // Simulate API call latency + return testData, nil + }) + if err != nil { + return nil, false, err + } + return result, false, nil // First call is cache miss + }) + if err != nil { + return err + } + + // Second operation should be a cache hit + _, err = tracer.TraceFunc("cache-operation", testKey, func() (interface{}, bool, error) { + result, err := cacheService.GetOrFetch(testKey, func() (interface{}, error) { + time.Sleep(10 * time.Millisecond) // This shouldn't be called + return testData, nil + }) + if err != nil { + return nil, false, err + } + return result, true, nil // Second call should be cache hit + }) + if err != nil { + return err + } + } else { + // Without cache, simulate direct API calls + _, err := tracer.TraceFunc("direct-api-call", "no-cache", func() (interface{}, bool, error) { + time.Sleep(10 * time.Millisecond) // Simulate API call + return nil, false, nil // Always a miss without cache + }) + if err != nil { + return err + } + + _, err = tracer.TraceFunc("direct-api-call", "no-cache", func() (interface{}, bool, error) { + time.Sleep(10 * time.Millisecond) // Simulate another API call + return nil, false, nil // Always a miss without cache + }) + if err != nil { + return err + } + } + return nil + }}, + } + + startTime := time.Now() + + for _, op := range operations { + for i := 0; i < *iterations; i++ { + if *verbose { + fmt.Fprintf(os.Stderr, "Running %s iteration %d/%d\n", op.name, i+1, *iterations) + } + + opStart := time.Now() + err := op.fn() + duration := time.Since(opStart) + + opResult := OperationResult{ + Operation: fmt.Sprintf("%s-%d", op.name, i+1), + Duration: duration, + CacheHit: false, // Will be determined from tracing stats + } + + if err != nil { + opResult.Error = err.Error() + if *verbose { + fmt.Fprintf(os.Stderr, "Error in %s: %v\n", op.name, err) + } + } + + result.OperationDetails = append(result.OperationDetails, opResult) + result.TotalOperations++ + + // Small delay between operations to allow for cache expiration testing + time.Sleep(100 * time.Millisecond) + } + } + + result.TotalDuration = time.Since(startTime) + if result.TotalOperations > 0 { + result.AverageDuration = result.TotalDuration / time.Duration(result.TotalOperations) + } + + // Get tracing statistics + if tracer != nil { + stats := tracer.GetStats() + result.TracingStats = map[string]interface{}{ + "cache_hits": stats.CacheHits, + "cache_misses": stats.CacheMisses, + "total_operations": stats.TotalOperations, + "avg_cache_hit_time": stats.AvgCacheHitTime, + "avg_cache_miss_time": stats.AvgCacheMissTime, + "hit_ratio": stats.HitRatio, + } + + result.CacheHits = int(stats.CacheHits) + result.CacheMisses = int(stats.CacheMisses) + result.CacheHitRatio = stats.HitRatio + } + + // Output results + if *outputJSON { + jsonData, err := json.MarshalIndent(result, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to marshal JSON: %v\n", err) + os.Exit(1) + } + fmt.Println(string(jsonData)) + } else { + fmt.Printf("=== Performance Benchmark Results ===\n") + fmt.Printf("Mode: %s\n", result.Mode) + fmt.Printf("Total Operations: %d\n", result.TotalOperations) + fmt.Printf("Total Duration: %v\n", result.TotalDuration) + fmt.Printf("Average Duration: %v\n", result.AverageDuration) + fmt.Printf("Cache Hits: %d\n", result.CacheHits) + fmt.Printf("Cache Misses: %d\n", result.CacheMisses) + fmt.Printf("Cache Hit Ratio: %.2f%%\n", result.CacheHitRatio*100) + + if result.TracingStats != nil { + fmt.Printf("\n=== Tracing Statistics ===\n") + for key, value := range result.TracingStats { + fmt.Printf("%s: %v\n", key, value) + } + } + + if *verbose { + fmt.Printf("\n=== Operation Details ===\n") + for _, op := range result.OperationDetails { + status := "success" + if op.Error != "" { + status = fmt.Sprintf("error: %s", op.Error) + } + fmt.Printf("%s: %v (%s)\n", op.Operation, op.Duration, status) + } + } + } +} \ No newline at end of file diff --git a/src/pkg/cache/cache.go b/src/pkg/cache/cache.go new file mode 100644 index 0000000..42d8626 --- /dev/null +++ b/src/pkg/cache/cache.go @@ -0,0 +1,110 @@ +package cache + +import ( + "sync" + "time" +) + +// CacheEntry represents a cached item with expiration time +type CacheEntry struct { + Value interface{} + ExpiresAt time.Time +} + +// Cache interface defines the caching operations +type Cache interface { + Get(key string) (interface{}, bool) + Set(key string, value interface{}, ttl time.Duration) + Delete(key string) + Clear() +} + +// MemoryCache implements an in-memory cache with TTL support +type MemoryCache struct { + items map[string]*CacheEntry + mutex sync.RWMutex +} + +// NewMemoryCache creates a new in-memory cache +func NewMemoryCache() *MemoryCache { + cache := &MemoryCache{ + items: make(map[string]*CacheEntry), + } + + // Start cleanup goroutine to remove expired entries + go cache.cleanup() + + return cache +} + +// Get retrieves a value from the cache +func (c *MemoryCache) Get(key string) (interface{}, bool) { + c.mutex.RLock() + defer c.mutex.RUnlock() + + entry, exists := c.items[key] + if !exists { + return nil, false + } + + // Check if entry has expired + if time.Now().After(entry.ExpiresAt) { + // Don't delete here to avoid write lock in read operation + return nil, false + } + + return entry.Value, true +} + +// Set stores a value in the cache with TTL +func (c *MemoryCache) Set(key string, value interface{}, ttl time.Duration) { + c.mutex.Lock() + defer c.mutex.Unlock() + + c.items[key] = &CacheEntry{ + Value: value, + ExpiresAt: time.Now().Add(ttl), + } +} + +// Delete removes a key from the cache +func (c *MemoryCache) Delete(key string) { + c.mutex.Lock() + defer c.mutex.Unlock() + + delete(c.items, key) +} + +// Clear removes all entries from the cache +func (c *MemoryCache) Clear() { + c.mutex.Lock() + defer c.mutex.Unlock() + + c.items = make(map[string]*CacheEntry) +} + +// cleanup periodically removes expired entries +func (c *MemoryCache) cleanup() { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + c.removeExpired() + } + } +} + +// removeExpired removes all expired entries from the cache +func (c *MemoryCache) removeExpired() { + c.mutex.Lock() + defer c.mutex.Unlock() + + now := time.Now() + for key, entry := range c.items { + if now.After(entry.ExpiresAt) { + delete(c.items, key) + } + } +} \ No newline at end of file diff --git a/src/pkg/cache/cache_test.go b/src/pkg/cache/cache_test.go new file mode 100644 index 0000000..51b15e5 --- /dev/null +++ b/src/pkg/cache/cache_test.go @@ -0,0 +1,156 @@ +package cache + +import ( + "testing" + "time" +) + +func TestMemoryCache(t *testing.T) { + cache := NewMemoryCache() + + // Test Set and Get + cache.Set("test_key", "test_value", 1*time.Second) + + value, found := cache.Get("test_key") + if !found { + t.Error("Expected to find cached value") + } + + if value != "test_value" { + t.Errorf("Expected 'test_value', got %v", value) + } + + // Test TTL expiration + cache.Set("expire_key", "expire_value", 100*time.Millisecond) + + // Should find it immediately + _, found = cache.Get("expire_key") + if !found { + t.Error("Expected to find cached value before expiration") + } + + // Wait for expiration + time.Sleep(150 * time.Millisecond) + + // Should not find it after expiration + _, found = cache.Get("expire_key") + if found { + t.Error("Expected cached value to be expired") + } + + // Test Delete + cache.Set("delete_key", "delete_value", 1*time.Minute) + cache.Delete("delete_key") + + _, found = cache.Get("delete_key") + if found { + t.Error("Expected cached value to be deleted") + } + + // Test Clear + cache.Set("clear_key1", "value1", 1*time.Minute) + cache.Set("clear_key2", "value2", 1*time.Minute) + + cache.Clear() + + _, found = cache.Get("clear_key1") + if found { + t.Error("Expected all cached values to be cleared") + } + + _, found = cache.Get("clear_key2") + if found { + t.Error("Expected all cached values to be cleared") + } +} + +func TestResourceCacheService(t *testing.T) { + service := NewResourceCacheService(1 * time.Second) + + fetchCount := 0 + fetchFunc := func() (interface{}, error) { + fetchCount++ + return "fetched_data", nil + } + + // First call should fetch data + data, err := service.GetOrFetch("test_key", fetchFunc) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if data != "fetched_data" { + t.Errorf("Expected 'fetched_data', got %v", data) + } + + if fetchCount != 1 { + t.Errorf("Expected fetch count to be 1, got %d", fetchCount) + } + + // Second call should use cache + data, err = service.GetOrFetch("test_key", fetchFunc) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if data != "fetched_data" { + t.Errorf("Expected 'fetched_data', got %v", data) + } + + if fetchCount != 1 { + t.Errorf("Expected fetch count to remain 1, got %d", fetchCount) + } + + // Wait for cache expiration + time.Sleep(1100 * time.Millisecond) + + // Third call should fetch again + data, err = service.GetOrFetch("test_key", fetchFunc) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if data != "fetched_data" { + t.Errorf("Expected 'fetched_data', got %v", data) + } + + if fetchCount != 2 { + t.Errorf("Expected fetch count to be 2, got %d", fetchCount) + } +} + +func TestCacheKeyGeneration(t *testing.T) { + // Test subscription key + subKey := GenerateSubscriptionKey() + if subKey != "subscriptions" { + t.Errorf("Expected 'subscriptions', got %s", subKey) + } + + // Test resource group key + rgKey := GenerateResourceGroupKey("sub123") + expected := "resourcegroups:sub123" + if rgKey != expected { + t.Errorf("Expected '%s', got %s", expected, rgKey) + } + + // Test resource key + resourceKey := GenerateResourceKey("sub123", "rg123", "Microsoft.Compute/virtualMachines") + expected = "resources:sub123:rg123:Microsoft.Compute/virtualMachines" + if resourceKey != expected { + t.Errorf("Expected '%s', got %s", expected, resourceKey) + } + + // Test VM key + vmKey := GenerateVMKey("sub123", "rg123") + expected = "vms:sub123:rg123" + if vmKey != expected { + t.Errorf("Expected '%s', got %s", expected, vmKey) + } + + // Test AKS key + aksKey := GenerateAKSKey("sub123", "rg123") + expected = "aks:sub123:rg123" + if aksKey != expected { + t.Errorf("Expected '%s', got %s", expected, aksKey) + } +} \ No newline at end of file diff --git a/src/pkg/cache/resource_cache.go b/src/pkg/cache/resource_cache.go new file mode 100644 index 0000000..8cf07b9 --- /dev/null +++ b/src/pkg/cache/resource_cache.go @@ -0,0 +1,112 @@ +package cache + +import ( + "fmt" + "time" + + "github.com/brendank310/aztui/pkg/tracing" +) + +// ResourceCacheService provides caching for Azure resources +type ResourceCacheService struct { + cache Cache + defaultTTL time.Duration +} + +// NewResourceCacheService creates a new resource cache service +func NewResourceCacheService(defaultTTL time.Duration) *ResourceCacheService { + return &ResourceCacheService{ + cache: NewMemoryCache(), + defaultTTL: defaultTTL, + } +} + +// GetOrFetch retrieves data from cache or fetches it using the provided function +func (s *ResourceCacheService) GetOrFetch(key string, fetchFunc func() (interface{}, error)) (interface{}, error) { + return s.GetOrFetchWithTTL(key, s.defaultTTL, fetchFunc) +} + +// GetOrFetchWithTTL retrieves data from cache or fetches it with custom TTL +func (s *ResourceCacheService) GetOrFetchWithTTL(key string, ttl time.Duration, fetchFunc func() (interface{}, error)) (interface{}, error) { + tracer := tracing.GetTracer() + + if tracer.IsEnabled() { + // Use tracing wrapper when enabled + return tracer.TraceFunc("cache_lookup", key, func() (interface{}, bool, error) { + // Try to get from cache first + if cached, found := s.cache.Get(key); found { + return cached, true, nil + } + + // Not in cache, fetch the data + data, err := fetchFunc() + if err != nil { + return nil, false, err + } + + // Store in cache + s.cache.Set(key, data, ttl) + + return data, false, nil + }) + } + + // Original implementation when tracing is disabled (no overhead) + // Try to get from cache first + if cached, found := s.cache.Get(key); found { + return cached, nil + } + + // Not in cache, fetch the data + data, err := fetchFunc() + if err != nil { + return nil, err + } + + // Store in cache + s.cache.Set(key, data, ttl) + + return data, nil +} + +// InvalidateKey removes a specific key from the cache +func (s *ResourceCacheService) InvalidateKey(key string) { + s.cache.Delete(key) +} + +// InvalidatePattern removes all keys matching a pattern (simple prefix matching) +func (s *ResourceCacheService) InvalidatePattern(prefix string) { + // For this simple implementation, we'll clear all cache + // A more sophisticated implementation could iterate through keys + s.cache.Clear() +} + +// Clear removes all entries from the cache +func (s *ResourceCacheService) Clear() { + s.cache.Clear() +} + +// GenerateSubscriptionKey creates a cache key for subscription list +func GenerateSubscriptionKey() string { + return "subscriptions" +} + +// GenerateResourceGroupKey creates a cache key for resource groups +func GenerateResourceGroupKey(subscriptionID string) string { + return fmt.Sprintf("resourcegroups:%s", subscriptionID) +} + +// GenerateResourceKey creates a cache key for resources +func GenerateResourceKey(subscriptionID, resourceGroup, resourceType string) string { + return fmt.Sprintf("resources:%s:%s:%s", subscriptionID, resourceGroup, resourceType) +} + +// GenerateVMKey creates a cache key for virtual machines +func GenerateVMKey(subscriptionID, resourceGroup string) string { + return fmt.Sprintf("vms:%s:%s", subscriptionID, resourceGroup) +} + +// GenerateAKSKey creates a cache key for AKS clusters +func GenerateAKSKey(subscriptionID, resourceGroup string) string { + return fmt.Sprintf("aks:%s:%s", subscriptionID, resourceGroup) +} \ No newline at end of file diff --git a/src/pkg/config/config.go b/src/pkg/config/config.go index a857cd4..627b42b 100644 --- a/src/pkg/config/config.go +++ b/src/pkg/config/config.go @@ -2,6 +2,7 @@ package config import ( "os" + "time" "gopkg.in/yaml.v3" ) @@ -19,8 +20,21 @@ type View struct { Actions []Action `yaml:"actions"` } +type CacheConfig struct { + TTLSeconds int `yaml:"ttlSeconds"` +} + type Config struct { - Views []View `yaml:"views"` + Views []View `yaml:"views"` + Cache CacheConfig `yaml:"cache"` +} + +// GetCacheTTL returns the cache TTL as a time.Duration +func (c *Config) GetCacheTTL() time.Duration { + if c.Cache.TTLSeconds <= 0 { + return 5 * time.Minute // Default to 5 minutes + } + return time.Duration(c.Cache.TTLSeconds) * time.Second } var GConfig Config diff --git a/src/pkg/resourceviews/aksclusters.go b/src/pkg/resourceviews/aksclusters.go index 09f953c..3ce6119 100644 --- a/src/pkg/resourceviews/aksclusters.go +++ b/src/pkg/resourceviews/aksclusters.go @@ -5,6 +5,7 @@ import ( "fmt" "log" + "github.com/brendank310/aztui/pkg/cache" "github.com/brendank310/aztui/pkg/config" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" @@ -121,41 +122,81 @@ func (v *AKSClusterListView) SpawnAKSClusterDetailView() tview.Primitive { } func (v *AKSClusterListView) Update() error { + // Use cache service for AKS cluster list + cacheService := GetCacheService() + if cacheService != nil { + cacheKey := cache.GenerateAKSKey(v.SubscriptionID, v.ResourceGroup) + + // Try to get cached AKS clusters first + data, err := cacheService.GetOrFetch(cacheKey, func() (interface{}, error) { + return v.fetchAKSClusters() + }) + + if err != nil { + return err + } + + // Cast the cached data back to the expected type + if clusters, ok := data.([]*armcontainerservice.ManagedCluster); ok { + v.populateList(clusters) + return nil + } + } + + // Fallback to direct fetch if cache service is not available + clusters, err := v.fetchAKSClusters() + if err != nil { + return err + } + + v.populateList(clusters) + return nil +} + +// fetchAKSClusters fetches AKS clusters from Azure API +func (v *AKSClusterListView) fetchAKSClusters() ([]*armcontainerservice.ManagedCluster, error) { cred, err := azidentity.NewDefaultAzureCredential(nil) if err != nil { - return fmt.Errorf("failed to obtain a credential: %v", err) + return nil, fmt.Errorf("failed to obtain a credential: %v", err) } - v.List.Clear() // Create a context ctx := context.Background() // Create a client to interact with AKS client, err := armcontainerservice.NewManagedClustersClient(v.SubscriptionID, cred, nil) if err != nil { - log.Fatalf("failed to create AKS client: %v", err) + return nil, fmt.Errorf("failed to create AKS client: %v", err) } + var clusters []*armcontainerservice.ManagedCluster + // List AKS clusters in the specified resource group clusterListPager := client.NewListByResourceGroupPager(v.ResourceGroup, nil) - // Check if the pager is empty - if !clusterListPager.More() { - v.List.AddItem("(No AKS clusters in resource group)", "", 0, nil) - } - // Iterate through the pager to fetch all AKS clusters for clusterListPager.More() { page, err := clusterListPager.NextPage(ctx) if err != nil { - log.Fatalf("failed to get the next page of AKS clusters: %v", err) + return nil, fmt.Errorf("failed to get the next page of AKS clusters: %v", err) } - // Loop through the AKS clusters and print their details - for _, cluster := range page.Value { - v.List.AddItem(*cluster.Name, *cluster.Properties.KubernetesVersion, 0, nil) - } + clusters = append(clusters, page.Value...) } - return nil + return clusters, nil +} + +// populateList populates the UI list with AKS cluster data +func (v *AKSClusterListView) populateList(clusters []*armcontainerservice.ManagedCluster) { + v.List.Clear() + + if len(clusters) == 0 { + v.List.AddItem("(No AKS clusters in resource group)", "", 0, nil) + return + } + + for _, cluster := range clusters { + v.List.AddItem(*cluster.Name, *cluster.Properties.KubernetesVersion, 0, nil) + } } diff --git a/src/pkg/resourceviews/resourcegroups.go b/src/pkg/resourceviews/resourcegroups.go index 5b5c66a..bee9b64 100644 --- a/src/pkg/resourceviews/resourcegroups.go +++ b/src/pkg/resourceviews/resourcegroups.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/brendank310/aztui/pkg/cache" "github.com/brendank310/aztui/pkg/config" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" @@ -121,35 +122,76 @@ func (r *ResourceGroupListView) SpawnAKSClusterListView() tview.Primitive { } func (r *ResourceGroupListView) Update() error { + // Use cache service for resource group list + cacheService := GetCacheService() + if cacheService != nil { + cacheKey := cache.GenerateResourceGroupKey(r.SubscriptionID) + + // Try to get cached resource groups first + data, err := cacheService.GetOrFetch(cacheKey, func() (interface{}, error) { + return r.fetchResourceGroups() + }) + + if err != nil { + return err + } + + // Cast the cached data back to the expected type + if resourceGroups, ok := data.([]ResourceGroupInfo); ok { + r.ResourceGroupList = &resourceGroups + r.populateList() + return nil + } + } + + // Fallback to direct fetch if cache service is not available + resourceGroups, err := r.fetchResourceGroups() + if err != nil { + return err + } + + r.ResourceGroupList = &resourceGroups + r.populateList() + return nil +} + +// fetchResourceGroups fetches resource groups from Azure API +func (r *ResourceGroupListView) fetchResourceGroups() ([]ResourceGroupInfo, error) { cred, err := azidentity.NewDefaultAzureCredential(nil) if err != nil { - return fmt.Errorf("failed to obtain a credential: %v", err) + return nil, fmt.Errorf("failed to obtain a credential: %v", err) } - r.List.Clear() rgClient, err := armresources.NewResourceGroupsClient(r.SubscriptionID, cred, nil) if err != nil { - return fmt.Errorf("failed to create resource groups client: %v", err) + return nil, fmt.Errorf("failed to create resource groups client: %v", err) } - r.ResourceGroupList = &[]ResourceGroupInfo{} + var resourceGroups []ResourceGroupInfo rgPager := rgClient.NewListPager(nil) for rgPager.More() { ctx := context.Background() page, err := rgPager.NextPage(ctx) if err != nil { - return fmt.Errorf("failed to get next resource groups page: %v", err) + return nil, fmt.Errorf("failed to get next resource groups page: %v", err) } for _, rg := range page.Value { resourceGroup := *rg.Name location := *rg.Location - *r.ResourceGroupList = append(*r.ResourceGroupList, ResourceGroupInfo{resourceGroup, location}) - r.List.AddItem(resourceGroup, location, 0, nil) + resourceGroups = append(resourceGroups, ResourceGroupInfo{resourceGroup, location}) } } - return nil + return resourceGroups, nil +} + +// populateList populates the UI list with resource group data +func (r *ResourceGroupListView) populateList() { + r.List.Clear() + for _, resourceGroupInfo := range *r.ResourceGroupList { + r.List.AddItem(resourceGroupInfo.ResourceGroupName, resourceGroupInfo.ResourceGroupLocation, 0, nil) + } } func (r *ResourceGroupListView) UpdateList(layout *AppLayout) error { diff --git a/src/pkg/resourceviews/resources.go b/src/pkg/resourceviews/resources.go index ae5d44e..70a1a71 100644 --- a/src/pkg/resourceviews/resources.go +++ b/src/pkg/resourceviews/resources.go @@ -6,6 +6,7 @@ import ( "log" "strings" + "github.com/brendank310/aztui/pkg/cache" "github.com/brendank310/aztui/pkg/config" "github.com/brendank310/aztui/pkg/logger" "github.com/gdamore/tcell/v2" @@ -153,18 +154,49 @@ func (v *ResourceListView) SpawnResourceDetailView() tview.Primitive { } func (v *ResourceListView) Update() error { - cred, err := azidentity.NewDefaultAzureCredential(nil) + // Use cache service for resource list + cacheService := GetCacheService() + if cacheService != nil { + cacheKey := cache.GenerateResourceKey(v.SubscriptionID, v.ResourceGroup, v.ResourceType) + + // Try to get cached resources first + data, err := cacheService.GetOrFetch(cacheKey, func() (interface{}, error) { + return v.fetchResources() + }) + + if err != nil { + return err + } + + // Cast the cached data back to the expected type + if resources, ok := data.([]*armresources.GenericResourceExpanded); ok { + v.populateList(resources) + return nil + } + } + + // Fallback to direct fetch if cache service is not available + resources, err := v.fetchResources() if err != nil { - return fmt.Errorf("failed to obtain a credential: %v", err) + return err } + + v.populateList(resources) + return nil +} - v.List.Clear() +// fetchResources fetches resources from Azure API +func (v *ResourceListView) fetchResources() ([]*armresources.GenericResourceExpanded, error) { + cred, err := azidentity.NewDefaultAzureCredential(nil) + if err != nil { + return nil, fmt.Errorf("failed to obtain a credential: %v", err) + } ctx := context.Background() resourcesClient, err := armresources.NewClient(v.SubscriptionID, cred, nil) if err != nil { - logger.Println("failed to create resources client: ", err) + return nil, fmt.Errorf("failed to create resources client: %v", err) } filter := fmt.Sprintf("resourceType eq '%s'", v.ResourceType) @@ -176,20 +208,31 @@ func (v *ResourceListView) Update() error { pager := resourcesClient.NewListByResourceGroupPager(v.ResourceGroup, options) - if !pager.More() { - v.List.AddItem(fmt.Sprintf("(No %v in resource group)", v.ResourceType), "", 0, nil) - } + var resources []*armresources.GenericResourceExpanded for pager.More() { page, err := pager.NextPage(ctx) if err != nil { logger.Println("failed to get the next page of", v.ResourceType, ":", err) + return nil, err } - for _, resource := range page.Value { - v.List.AddItem(*resource.Name, *resource.Location, 0, nil) - } + resources = append(resources, page.Value...) } - return nil + return resources, nil +} + +// populateList populates the UI list with resource data +func (v *ResourceListView) populateList(resources []*armresources.GenericResourceExpanded) { + v.List.Clear() + + if len(resources) == 0 { + v.List.AddItem(fmt.Sprintf("(No %v in resource group)", v.ResourceType), "", 0, nil) + return + } + + for _, resource := range resources { + v.List.AddItem(*resource.Name, *resource.Location, 0, nil) + } } diff --git a/src/pkg/resourceviews/subscriptions.go b/src/pkg/resourceviews/subscriptions.go index 8d9797d..1cc2a9c 100644 --- a/src/pkg/resourceviews/subscriptions.go +++ b/src/pkg/resourceviews/subscriptions.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/brendank310/aztui/pkg/cache" "github.com/brendank310/aztui/pkg/config" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" @@ -103,19 +104,52 @@ func (s *SubscriptionListView) SpawnResourceGroupListView() tview.Primitive { } func (s *SubscriptionListView) Update() error { + // Use cache service for subscription list + cacheService := GetCacheService() + if cacheService != nil { + cacheKey := cache.GenerateSubscriptionKey() + + // Try to get cached subscriptions first + data, err := cacheService.GetOrFetch(cacheKey, func() (interface{}, error) { + return s.fetchSubscriptions() + }) + + if err != nil { + return err + } + + // Cast the cached data back to the expected type + if subscriptions, ok := data.([]SubscriptionInfo); ok { + s.SubscriptionList = &subscriptions + s.populateList() + return nil + } + } + + // Fallback to direct fetch if cache service is not available + subscriptions, err := s.fetchSubscriptions() + if err != nil { + return err + } + + s.SubscriptionList = &subscriptions + s.populateList() + return nil +} + +// fetchSubscriptions fetches subscriptions from Azure API +func (s *SubscriptionListView) fetchSubscriptions() ([]SubscriptionInfo, error) { cred, err := azidentity.NewDefaultAzureCredential(nil) if err != nil { - return fmt.Errorf("failed to obtain a credential: %v", err) + return nil, fmt.Errorf("failed to obtain a credential: %v", err) } subClient, err := armsubscriptions.NewClient(cred, nil) if err != nil { - return fmt.Errorf("failed to create subscriptions client: %v", err) + return nil, fmt.Errorf("failed to create subscriptions client: %v", err) } - // Initialize the subscription list - s.SubscriptionList = &[]SubscriptionInfo{} - s.List.Clear() + var subscriptions []SubscriptionInfo // List subscriptions subPager := subClient.NewListPager(nil) @@ -123,17 +157,24 @@ func (s *SubscriptionListView) Update() error { for subPager.More() { page, err := subPager.NextPage(ctx) if err != nil { - return fmt.Errorf("failed to get next subscriptions page: %v", err) + return nil, fmt.Errorf("failed to get next subscriptions page: %v", err) } for _, subscription := range page.Value { subscriptionID := *subscription.SubscriptionID subscriptionName := *subscription.DisplayName - s.List.AddItem(subscriptionName, subscriptionID, 0, nil) - *s.SubscriptionList = append(*s.SubscriptionList, SubscriptionInfo{subscriptionName, subscriptionID}) + subscriptions = append(subscriptions, SubscriptionInfo{subscriptionName, subscriptionID}) } } - return nil + return subscriptions, nil +} + +// populateList populates the UI list with subscription data +func (s *SubscriptionListView) populateList() { + s.List.Clear() + for _, subscriptionInfo := range *s.SubscriptionList { + s.List.AddItem(subscriptionInfo.SubscriptionName, subscriptionInfo.SubscriptionID, 0, nil) + } } func (s *SubscriptionListView) UpdateList(layout *AppLayout) error { diff --git a/src/pkg/resourceviews/types.go b/src/pkg/resourceviews/types.go index 030e3b9..3b2835c 100644 --- a/src/pkg/resourceviews/types.go +++ b/src/pkg/resourceviews/types.go @@ -1,6 +1,21 @@ package resourceviews +import "github.com/brendank310/aztui/pkg/cache" + var AvailableResourceTypes = []string{ "Virtual Machines", "AKS Clusters", } + +// Global cache service instance +var globalCacheService *cache.ResourceCacheService + +// SetCacheService sets the global cache service instance +func SetCacheService(cacheService *cache.ResourceCacheService) { + globalCacheService = cacheService +} + +// GetCacheService returns the global cache service instance +func GetCacheService() *cache.ResourceCacheService { + return globalCacheService +} diff --git a/src/pkg/resourceviews/virtualmachines.go b/src/pkg/resourceviews/virtualmachines.go index 5d00d84..4f70743 100644 --- a/src/pkg/resourceviews/virtualmachines.go +++ b/src/pkg/resourceviews/virtualmachines.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/brendank310/aztui/pkg/azcli" + "github.com/brendank310/aztui/pkg/cache" "github.com/brendank310/aztui/pkg/config" "github.com/brendank310/aztui/pkg/consoles" "github.com/gdamore/tcell/v2" @@ -204,35 +205,77 @@ func (v *VirtualMachineListView) SpawnVirtualMachineCommandListView() tview.Prim } func (v *VirtualMachineListView) Update() error { + // Use cache service for virtual machine list + cacheService := GetCacheService() + if cacheService != nil { + cacheKey := cache.GenerateVMKey(v.SubscriptionID, v.ResourceGroup) + + // Try to get cached VMs first + data, err := cacheService.GetOrFetch(cacheKey, func() (interface{}, error) { + return v.fetchVirtualMachines() + }) + + if err != nil { + return err + } + + // Cast the cached data back to the expected type + if vms, ok := data.([]*armcompute.VirtualMachine); ok { + v.populateList(vms) + return nil + } + } + + // Fallback to direct fetch if cache service is not available + vms, err := v.fetchVirtualMachines() + if err != nil { + return err + } + + v.populateList(vms) + return nil +} + +// fetchVirtualMachines fetches virtual machines from Azure API +func (v *VirtualMachineListView) fetchVirtualMachines() ([]*armcompute.VirtualMachine, error) { cred, err := azidentity.NewDefaultAzureCredential(nil) if err != nil { - return fmt.Errorf("failed to obtain a credential: %v", err) + return nil, fmt.Errorf("failed to obtain a credential: %v", err) } - v.List.Clear() vmClient, err := armcompute.NewVirtualMachinesClient(v.SubscriptionID, cred, nil) if err != nil { - log.Fatalf("failed to create virtual machines client: %v", err) + return nil, fmt.Errorf("failed to create virtual machines client: %v", err) } + var vms []*armcompute.VirtualMachine + vmPager := vmClient.NewListPager(v.ResourceGroup, nil) for vmPager.More() { ctx := context.Background() page, err := vmPager.NextPage(ctx) if err != nil { - log.Fatalf("failed to get next virtual machines page: %v", err) + return nil, fmt.Errorf("failed to get next virtual machines page: %v", err) } - if len(page.Value) == 0 && !vmPager.More() { - v.List.AddItem("(No VMs in resource group)", "", 0, nil) - } + vms = append(vms, page.Value...) + } - for _, vm := range page.Value { - vmName := *vm.Name - vmLocation := *vm.Location - v.List.AddItem(vmName, vmLocation, 0, nil) - } + return vms, nil +} + +// populateList populates the UI list with virtual machine data +func (v *VirtualMachineListView) populateList(vms []*armcompute.VirtualMachine) { + v.List.Clear() + + if len(vms) == 0 { + v.List.AddItem("(No VMs in resource group)", "", 0, nil) + return } - return nil + for _, vm := range vms { + vmName := *vm.Name + vmLocation := *vm.Location + v.List.AddItem(vmName, vmLocation, 0, nil) + } } diff --git a/src/pkg/tracing/tracing.go b/src/pkg/tracing/tracing.go new file mode 100644 index 0000000..da44119 --- /dev/null +++ b/src/pkg/tracing/tracing.go @@ -0,0 +1,216 @@ +package tracing + +import ( + "encoding/json" + "fmt" + "os" + "runtime/pprof" + "sync" + "time" +) + +// PerformanceStats holds statistics about cache performance +type PerformanceStats struct { + CacheHits int64 `json:"cache_hits"` + CacheMisses int64 `json:"cache_misses"` + TotalOperations int64 `json:"total_operations"` + HitRatio float64 `json:"hit_ratio"` + AvgCacheHitTime time.Duration `json:"avg_cache_hit_time_ns"` + AvgCacheMissTime time.Duration `json:"avg_cache_miss_time_ns"` + TotalCacheHitTime time.Duration `json:"total_cache_hit_time_ns"` + TotalCacheMissTime time.Duration `json:"total_cache_miss_time_ns"` +} + +// OperationTrace represents a single operation trace +type OperationTrace struct { + Operation string `json:"operation"` + CacheKey string `json:"cache_key"` + Duration time.Duration `json:"duration_ns"` + WasCacheHit bool `json:"was_cache_hit"` + Timestamp time.Time `json:"timestamp"` +} + +// PerformanceTracer provides tracing and statistics for performance analysis +type PerformanceTracer struct { + enabled bool + mutex sync.RWMutex + stats PerformanceStats + traces []OperationTrace + maxTraces int + cpuProfile *os.File +} + +var ( + globalTracer *PerformanceTracer + once sync.Once +) + +// InitTracer initializes the global performance tracer +func InitTracer(enabled bool, maxTraces int) { + once.Do(func() { + globalTracer = &PerformanceTracer{ + enabled: enabled, + maxTraces: maxTraces, + traces: make([]OperationTrace, 0, maxTraces), + } + }) +} + +// GetTracer returns the global tracer instance +func GetTracer() *PerformanceTracer { + if globalTracer == nil { + InitTracer(false, 1000) // Default disabled tracer + } + return globalTracer +} + +// IsEnabled returns whether tracing is enabled +func (t *PerformanceTracer) IsEnabled() bool { + t.mutex.RLock() + defer t.mutex.RUnlock() + return t.enabled +} + +// StartCPUProfile starts CPU profiling for flamegraph generation +func (t *PerformanceTracer) StartCPUProfile(filename string) error { + if !t.enabled { + return nil + } + + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("could not create CPU profile: %v", err) + } + + if err := pprof.StartCPUProfile(file); err != nil { + file.Close() + return fmt.Errorf("could not start CPU profile: %v", err) + } + + t.mutex.Lock() + t.cpuProfile = file + t.mutex.Unlock() + + return nil +} + +// StopCPUProfile stops CPU profiling +func (t *PerformanceTracer) StopCPUProfile() { + if !t.enabled { + return + } + + pprof.StopCPUProfile() + + t.mutex.Lock() + if t.cpuProfile != nil { + t.cpuProfile.Close() + t.cpuProfile = nil + } + t.mutex.Unlock() +} + +// TraceOperation records the timing and result of a cache operation +func (t *PerformanceTracer) TraceOperation(operation, cacheKey string, duration time.Duration, wasCacheHit bool) { + if !t.enabled { + return + } + + t.mutex.Lock() + defer t.mutex.Unlock() + + // Update statistics + t.stats.TotalOperations++ + if wasCacheHit { + t.stats.CacheHits++ + t.stats.TotalCacheHitTime += duration + t.stats.AvgCacheHitTime = t.stats.TotalCacheHitTime / time.Duration(t.stats.CacheHits) + } else { + t.stats.CacheMisses++ + t.stats.TotalCacheMissTime += duration + t.stats.AvgCacheMissTime = t.stats.TotalCacheMissTime / time.Duration(t.stats.CacheMisses) + } + + if t.stats.TotalOperations > 0 { + t.stats.HitRatio = float64(t.stats.CacheHits) / float64(t.stats.TotalOperations) + } + + // Add trace if we have room + if len(t.traces) < t.maxTraces { + trace := OperationTrace{ + Operation: operation, + CacheKey: cacheKey, + Duration: duration, + WasCacheHit: wasCacheHit, + Timestamp: time.Now(), + } + t.traces = append(t.traces, trace) + } +} + +// GetStats returns a copy of current performance statistics +func (t *PerformanceTracer) GetStats() PerformanceStats { + t.mutex.RLock() + defer t.mutex.RUnlock() + return t.stats +} + +// GetTraces returns a copy of current traces +func (t *PerformanceTracer) GetTraces() []OperationTrace { + t.mutex.RLock() + defer t.mutex.RUnlock() + + traces := make([]OperationTrace, len(t.traces)) + copy(traces, t.traces) + return traces +} + +// OutputStats outputs performance statistics to stderr as JSON +func (t *PerformanceTracer) OutputStats() { + if !t.enabled { + return + } + + stats := t.GetStats() + traces := t.GetTraces() + + output := struct { + Stats PerformanceStats `json:"performance_stats"` + Traces []OperationTrace `json:"traces,omitempty"` + }{ + Stats: stats, + Traces: traces, + } + + jsonData, err := json.MarshalIndent(output, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "Error marshaling performance data: %v\n", err) + return + } + + fmt.Fprintf(os.Stderr, "%s\n", jsonData) +} + +// Reset clears all statistics and traces +func (t *PerformanceTracer) Reset() { + t.mutex.Lock() + defer t.mutex.Unlock() + + t.stats = PerformanceStats{} + t.traces = t.traces[:0] +} + +// TraceFunc is a helper that measures the execution time of a function +func (t *PerformanceTracer) TraceFunc(operation, cacheKey string, fn func() (interface{}, bool, error)) (interface{}, error) { + if !t.enabled { + result, _, err := fn() + return result, err + } + + start := time.Now() + result, wasCacheHit, err := fn() + duration := time.Since(start) + + t.TraceOperation(operation, cacheKey, duration, wasCacheHit) + return result, err +} \ No newline at end of file diff --git a/src/pkg/tracing/tracing_test.go b/src/pkg/tracing/tracing_test.go new file mode 100644 index 0000000..91ce02b --- /dev/null +++ b/src/pkg/tracing/tracing_test.go @@ -0,0 +1,207 @@ +package tracing + +import ( + "sync" + "testing" + "time" +) + +func TestPerformanceTracer_Disabled(t *testing.T) { + // Test that disabled tracer has no overhead + tracer := &PerformanceTracer{enabled: false} + + start := time.Now() + tracer.TraceOperation("test", "key", time.Millisecond, true) + elapsed := time.Since(start) + + // Should be very fast when disabled + if elapsed > time.Microsecond*10 { + t.Errorf("Disabled tracer took too long: %v", elapsed) + } + + stats := tracer.GetStats() + if stats.TotalOperations != 0 { + t.Errorf("Expected 0 operations, got %d", stats.TotalOperations) + } +} + +func TestPerformanceTracer_CacheStats(t *testing.T) { + tracer := &PerformanceTracer{ + enabled: true, + maxTraces: 100, + traces: make([]OperationTrace, 0, 100), + } + + // Record some cache hits + tracer.TraceOperation("get_subscriptions", "subscriptions", time.Millisecond*10, true) + tracer.TraceOperation("get_subscriptions", "subscriptions", time.Millisecond*5, true) + + // Record some cache misses + tracer.TraceOperation("get_resource_groups", "rg:sub1", time.Millisecond*100, false) + tracer.TraceOperation("get_resources", "res:sub1:rg1", time.Millisecond*200, false) + + stats := tracer.GetStats() + + // Verify counts + if stats.CacheHits != 2 { + t.Errorf("Expected 2 cache hits, got %d", stats.CacheHits) + } + if stats.CacheMisses != 2 { + t.Errorf("Expected 2 cache misses, got %d", stats.CacheMisses) + } + if stats.TotalOperations != 4 { + t.Errorf("Expected 4 total operations, got %d", stats.TotalOperations) + } + + // Verify hit ratio + expectedHitRatio := 0.5 + if stats.HitRatio != expectedHitRatio { + t.Errorf("Expected hit ratio %f, got %f", expectedHitRatio, stats.HitRatio) + } + + // Verify average times + expectedAvgHitTime := (time.Millisecond*10 + time.Millisecond*5) / 2 // (10+5)/2 + if stats.AvgCacheHitTime != expectedAvgHitTime { + t.Errorf("Expected avg hit time %v, got %v", expectedAvgHitTime, stats.AvgCacheHitTime) + } + + expectedAvgMissTime := (time.Millisecond*100 + time.Millisecond*200) / 2 // (100+200)/2 + if stats.AvgCacheMissTime != expectedAvgMissTime { + t.Errorf("Expected avg miss time %v, got %v", expectedAvgMissTime, stats.AvgCacheMissTime) + } +} + +func TestPerformanceTracer_Traces(t *testing.T) { + tracer := &PerformanceTracer{ + enabled: true, + maxTraces: 2, // Small limit to test overflow + traces: make([]OperationTrace, 0, 2), + } + + // Add traces + tracer.TraceOperation("op1", "key1", time.Millisecond, true) + tracer.TraceOperation("op2", "key2", time.Millisecond*2, false) + tracer.TraceOperation("op3", "key3", time.Millisecond*3, true) // Should not be stored due to limit + + traces := tracer.GetTraces() + + // Should only have 2 traces due to limit + if len(traces) != 2 { + t.Errorf("Expected 2 traces, got %d", len(traces)) + } + + // Verify first trace + if traces[0].Operation != "op1" { + t.Errorf("Expected operation 'op1', got '%s'", traces[0].Operation) + } + if traces[0].CacheKey != "key1" { + t.Errorf("Expected cache key 'key1', got '%s'", traces[0].CacheKey) + } + if !traces[0].WasCacheHit { + t.Errorf("Expected cache hit for first trace") + } + + // Verify second trace + if traces[1].Operation != "op2" { + t.Errorf("Expected operation 'op2', got '%s'", traces[1].Operation) + } + if traces[1].WasCacheHit { + t.Errorf("Expected cache miss for second trace") + } +} + +func TestPerformanceTracer_Reset(t *testing.T) { + tracer := &PerformanceTracer{ + enabled: true, + maxTraces: 100, + traces: make([]OperationTrace, 0, 100), + } + + // Add some data + tracer.TraceOperation("test", "key", time.Millisecond, true) + + // Verify data exists + stats := tracer.GetStats() + if stats.TotalOperations == 0 { + t.Error("Expected operations before reset") + } + + traces := tracer.GetTraces() + if len(traces) == 0 { + t.Error("Expected traces before reset") + } + + // Reset + tracer.Reset() + + // Verify data is cleared + stats = tracer.GetStats() + if stats.TotalOperations != 0 { + t.Errorf("Expected 0 operations after reset, got %d", stats.TotalOperations) + } + + traces = tracer.GetTraces() + if len(traces) != 0 { + t.Errorf("Expected 0 traces after reset, got %d", len(traces)) + } +} + +func TestPerformanceTracer_TraceFunc(t *testing.T) { + tracer := &PerformanceTracer{ + enabled: true, + maxTraces: 100, + traces: make([]OperationTrace, 0, 100), + } + + // Test cache hit scenario + result, err := tracer.TraceFunc("test_op", "test_key", func() (interface{}, bool, error) { + time.Sleep(time.Millisecond) // Simulate some work + return "cached_result", true, nil + }) + + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if result != "cached_result" { + t.Errorf("Expected 'cached_result', got %v", result) + } + + stats := tracer.GetStats() + if stats.CacheHits != 1 { + t.Errorf("Expected 1 cache hit, got %d", stats.CacheHits) + } + + traces := tracer.GetTraces() + if len(traces) != 1 { + t.Errorf("Expected 1 trace, got %d", len(traces)) + } + if traces[0].Operation != "test_op" { + t.Errorf("Expected operation 'test_op', got '%s'", traces[0].Operation) + } + if !traces[0].WasCacheHit { + t.Error("Expected cache hit in trace") + } +} + +func TestGlobalTracer(t *testing.T) { + // Reset global tracer + globalTracer = nil + once = sync.Once{} + + // Initialize global tracer + InitTracer(true, 100) + + tracer := GetTracer() + if tracer == nil { + t.Error("Expected non-nil global tracer") + } + if !tracer.IsEnabled() { + t.Error("Expected tracer to be enabled") + } + + // Test that subsequent calls return the same instance + tracer2 := GetTracer() + if tracer != tracer2 { + t.Error("Expected same tracer instance") + } +} \ No newline at end of file