diff --git a/frontend-modern/src/components/shared/HistoryChart.tsx b/frontend-modern/src/components/shared/HistoryChart.tsx new file mode 100644 index 000000000..60f242b6b --- /dev/null +++ b/frontend-modern/src/components/shared/HistoryChart.tsx @@ -0,0 +1,409 @@ +/** + * HistoryChart Component + * + * Canvas-based chart for displaying historical metrics data (up to 90 days). + * Includes user-friendly empty states and Pro-tier gating for >24h data. + */ + +import { Component, createEffect, createSignal, onCleanup, Show, createMemo, onMount } from 'solid-js'; +import { ChartsAPI, type ResourceType, type HistoryTimeRange, type AggregatedMetricPoint } from '@/api/charts'; +import { hasFeature, loadLicenseStatus } from '@/stores/license'; +import { Portal } from 'solid-js/web'; +import { formatBytes } from '@/utils/format'; + +interface HistoryChartProps { + resourceType: ResourceType; + resourceId: string; + metric: 'cpu' | 'memory' | 'disk'; + height?: number; + color?: string; + label?: string; + unit?: string; + range?: HistoryTimeRange; + onRangeChange?: (range: HistoryTimeRange) => void; + hideSelector?: boolean; +} + +export const HistoryChart: Component = (props) => { + let canvasRef: HTMLCanvasElement | undefined; + let containerRef: HTMLDivElement | undefined; + + const [range, setRange] = createSignal(props.range || '24h'); + const [data, setData] = createSignal([]); + const [loading, setLoading] = createSignal(false); + const [error, setError] = createSignal(null); + + // Load license status on mount to ensure hasFeature works correctly + onMount(() => { + loadLicenseStatus(); + }); + + // Sync internal range with props.range + createEffect(() => { + if (props.range) { + setRange(props.range); + } + }); + + // Handle range change + const updateRange = (newRange: HistoryTimeRange) => { + setRange(newRange); + if (props.onRangeChange) { + props.onRangeChange(newRange); + } + }; + + // Feature gating check + const isLongTermEnabled = () => hasFeature('long_term_metrics'); + + // Check if current view is locked + const isLocked = createMemo(() => { + const r = range(); + // Lock if range > 7d and feature not enabled (7d is free, 30d/90d require Pro) + return !isLongTermEnabled() && (r === '30d' || r === '90d'); + }); + + // Hover state for tooltip + const [hoveredPoint, setHoveredPoint] = createSignal<{ + value: number; + min: number; + max: number; + timestamp: number; + x: number; + y: number; + } | null>(null); + + // Fetch data when range or resource changes + createEffect(async () => { + const r = range(); + const type = props.resourceType; + const id = props.resourceId; + const metric = props.metric; + + if (!id || !type) return; + + // If locked, we don't fetch data (or we fetch 24h data to show blurred?) + // Better: Fetch data even if locked, let the API enforce the cap (which we did), + // or just don't fetch and show the lock screen immediately? + // Decision: If locked, show the lock screen over the *previous* data or just empty. + // But to make it look nice "blurred", we might want some data. + // However, the API enforces 24h cap. So fetching '7d' will return 24h data. + // We can display that 24h data scaled to 7d (which would look short) or just show the lock overlay. + // Let's just fetch. The API will return what's allowed. + + setLoading(true); + setError(null); + try { + const result = await ChartsAPI.getMetricsHistory({ + resourceType: type, + resourceId: id, + metric: metric, + range: r + }); + + if ('points' in result) { + setData(result.points || []); + } else { + // Should not happen as we request single metric + setData([]); + } + } catch (err) { + console.error('Failed to fetch metrics history:', err); + setError('Failed to load history data'); + } finally { + setLoading(false); + } + }); + + // Draw chart + const drawChart = () => { + if (!canvasRef) return; + const canvas = canvasRef; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const points = data(); + const w = canvas.parentElement?.clientWidth || 300; + const h = props.height || 200; + + // Handle device pixel ratio + const dpr = window.devicePixelRatio || 1; + canvas.width = w * dpr; + canvas.height = h * dpr; + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + ctx.scale(dpr, dpr); + + // Clear + ctx.clearRect(0, 0, w, h); + + // Colors + const isDark = document.documentElement.classList.contains('dark'); + const gridColor = isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)'; + const textColor = isDark ? '#9ca3af' : '#6b7280'; + + // Dynamic color based on prop or default + let mainColor = props.color || '#3b82f6'; // blue-500 + if (props.metric === 'cpu') mainColor = '#8b5cf6'; // violet-500 + if (props.metric === 'memory') mainColor = '#f59e0b'; // amber-500 + if (props.metric === 'disk') mainColor = '#10b981'; // emerald-500 + + // Draw grid lines (horizontal) + ctx.strokeStyle = gridColor; + ctx.lineWidth = 1; + + // 0%, 50%, 100% lines + [0, 0.5, 1].forEach(pct => { + const y = h - 20 - (pct * (h - 40)); // padding + ctx.beginPath(); + ctx.moveTo(40, y); + ctx.lineTo(w, y); + ctx.stroke(); + + // Y-Axis labels + ctx.fillStyle = textColor; + ctx.font = '10px sans-serif'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + let label = ''; + if (pct === 0) label = '0'; + else if (pct === 1) label = props.unit === '%' ? '100' : 'Max'; + else label = props.unit === '%' ? '50' : 'Avg'; + + if (props.unit === '%') label += '%'; + ctx.fillText(label, 35, y); + }); + + // If no data or loading + if (points.length === 0) { + return; // Empty state handled in JSX + } + + // Calculate Scale + // X is time, Y is value + const startTime = points[0].timestamp; + const endTime = points[points.length - 1].timestamp; + const timeSpan = Math.max(1, endTime - startTime); + + const maxValue = Math.max(100, ...points.map(p => p.max || p.value)); + const minValue = 0; + + // Plot + const getX = (ts: number) => 40 + ((ts - startTime) / timeSpan) * (w - 40); + const getY = (val: number) => (h - 20) - ((val - minValue) / (maxValue - minValue)) * (h - 40); + + // Fill area + ctx.beginPath(); + points.forEach((p, i) => { + if (i === 0) ctx.moveTo(getX(p.timestamp), h - 20); + ctx.lineTo(getX(p.timestamp), getY(p.value)); + }); + if (points.length > 0) { + ctx.lineTo(getX(points[points.length - 1].timestamp), h - 20); + } + ctx.closePath(); + + const gradient = ctx.createLinearGradient(0, 0, 0, h); + gradient.addColorStop(0, `${mainColor}66`); // 40% + gradient.addColorStop(1, `${mainColor}11`); // 6% + ctx.fillStyle = gradient; + ctx.fill(); + + // Stroke line + ctx.beginPath(); + ctx.strokeStyle = mainColor; + ctx.lineWidth = 2; + points.forEach((p, i) => { + if (i === 0) ctx.moveTo(getX(p.timestamp), getY(p.value)); + else ctx.lineTo(getX(p.timestamp), getY(p.value)); + }); + ctx.stroke(); + + // Min/Max envelope (optional, for pro feel?) + // Let's keep it clean for now, maybe add later. + }; + + // Reactivity + createEffect(() => { + drawChart(); + }); + + // Resize observer + createEffect(() => { + if (!containerRef) return; + const resizeObserver = new ResizeObserver(() => drawChart()); + resizeObserver.observe(containerRef); + onCleanup(() => resizeObserver.disconnect()); + }); + + // Mouse interaction + const handleMouseMove = (e: MouseEvent) => { + if (!canvasRef || data().length === 0) return; + const rect = canvasRef.getBoundingClientRect(); + const x = e.clientX - rect.left; + const points = data(); + + const w = rect.width; + // Map x to timestamp + const startTime = points[0].timestamp; + const endTime = points[points.length - 1].timestamp; + const timeSpan = endTime - startTime; + + // Inverse getX: x = 40 + ratio * (w-40) + // ratio = (x - 40) / (w - 40) + if (x < 40) return; + const ratio = (x - 40) / (w - 40); + const hoverTs = startTime + ratio * timeSpan; + + // Find nearest point + // Using simple binary search/scan is efficient enough for ~1000 points? + // Find index with minimal timestamps diff + let closest = points[0]; + let minDiff = Math.abs(points[0].timestamp - hoverTs); + + // Optimisation: direct index calculation if uniform, but it's not guaranteed. + // Iterating is fast enough for < 10000 points. + for (const p of points) { + const diff = Math.abs(p.timestamp - hoverTs); + if (diff < minDiff) { + minDiff = diff; + closest = p; + } + } + + setHoveredPoint({ + value: closest.value, + min: closest.min || closest.value, + max: closest.max || closest.value, + timestamp: closest.timestamp, + x: rect.left + x, + y: rect.top + 20, // Approximate + }); + }; + + const handleMouseLeave = () => setHoveredPoint(null); + + const ranges: HistoryTimeRange[] = ['24h', '7d', '30d', '90d']; + + return ( +
+
+
+ {props.label || 'History'} + + ({props.unit}) + +
+ + {/* Time Range Selector */} + +
+ {ranges.map(r => ( + + ))} +
+
+
+ +
+ + + {/* Empty State */} + +
+
+
+ + + + + + + +
+

Collecting data... History will appear here.

+
+
+
+ + {/* Loading State */} + +
+
+
+ + + {/* Error State */} + +
+

{error()}

+
+
+ + {/* Pro Lock Overlay */} + +
+
+ + + + +
+

90-Day History

+

+ Upgrade to Pulse Pro to unlock 90 days of historical data retention. +

+ + Unlock Pro Features + +
+
+
+ + + + {(point) => ( +
+
{new Date(point().timestamp).toLocaleString()}
+
+ {props.unit === '%' ? + `${point().value.toFixed(1)}%` : + formatBytes(point().value)} +
+ +
+ Min: {props.unit === '%' ? point().min.toFixed(1) : formatBytes(point().min)} • + Max: {props.unit === '%' ? point().max.toFixed(1) : formatBytes(point().max)} +
+
+
+ )} +
+
+
+ ); +}; diff --git a/frontend-modern/src/components/shared/UnifiedHistoryChart.tsx b/frontend-modern/src/components/shared/UnifiedHistoryChart.tsx new file mode 100644 index 000000000..68ecb2f51 --- /dev/null +++ b/frontend-modern/src/components/shared/UnifiedHistoryChart.tsx @@ -0,0 +1,334 @@ +import { Component, createSignal, createEffect, onCleanup, onMount, Show, For } from 'solid-js'; +import { Portal } from 'solid-js/web'; +import { AggregatedMetricPoint, ChartsAPI, HistoryTimeRange, ResourceType } from '@/api/charts'; +import { formatBytes } from '@/utils/format'; +import { hasFeature, loadLicenseStatus } from '@/stores/license'; + +interface UnifiedHistoryChartProps { + resourceType: ResourceType; + resourceId: string; + height?: number; + label?: string; + range?: HistoryTimeRange; + onRangeChange?: (range: HistoryTimeRange) => void; + hideSelector?: boolean; +} + +interface HoverInfo { + timestamp: number; + x: number; + y: number; + metrics: Record; +} + +export const UnifiedHistoryChart: Component = (props) => { + let canvasRef: HTMLCanvasElement | undefined; + let containerRef: HTMLDivElement | undefined; + + const [range, setRange] = createSignal(props.range || '24h'); + const [metricsData, setMetricsData] = createSignal>({}); + const [loading, setLoading] = createSignal(false); + const [error, setError] = createSignal(null); + const [hoveredPoint, setHoveredPoint] = createSignal(null); + + const metricConfigs = { + cpu: { label: 'CPU', color: '#8b5cf6', unit: '%' }, // violet-500 + memory: { label: 'Memory', color: '#f59e0b', unit: '%' }, // amber-500 + disk: { label: 'Disk', color: '#10b981', unit: '%' } // emerald-500 + }; + + const loadData = async (resourceType: ResourceType, resourceId: string, rangeValue: HistoryTimeRange) => { + setLoading(true); + setError(null); + try { + // Fetch all metrics for the resource + const response = await ChartsAPI.getMetricsHistory({ + resourceType, + resourceId, + range: rangeValue + }); + + if ('metrics' in response) { + setMetricsData(response.metrics); + } else { + // Should not happen with multi-metric query, but handle fallback + setMetricsData({ [response.metric]: response.points }); + } + } catch (err: any) { + console.error('[UnifiedHistoryChart] Failed to load history:', err); + setError('Failed to load history data'); + } finally { + setLoading(false); + } + }; + + onMount(() => { + loadLicenseStatus(); + }); + + createEffect(() => { + if (props.range) setRange(props.range); + }); + + createEffect(() => { + const resourceType = props.resourceType; + const resourceId = props.resourceId; + const rangeValue = props.range ?? range(); + + if (!resourceType || !resourceId) return; + loadData(resourceType, resourceId, rangeValue); + }); + + const drawChart = () => { + if (!canvasRef) return; + const ctx = canvasRef.getContext('2d'); + if (!ctx) return; + + const w = canvasRef.parentElement?.clientWidth || 300; + const h = props.height || 200; + + const dpr = window.devicePixelRatio || 1; + canvasRef.width = w * dpr; + canvasRef.height = h * dpr; + canvasRef.style.width = `${w}px`; + canvasRef.style.height = `${h}px`; + ctx.scale(dpr, dpr); + + ctx.clearRect(0, 0, w, h); + + const isDark = document.documentElement.classList.contains('dark'); + const gridColor = isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)'; + const textColor = isDark ? '#9ca3af' : '#6b7280'; + + // Draw grid + ctx.strokeStyle = gridColor; + ctx.lineWidth = 1; + [0, 0.5, 1].forEach(pct => { + const y = h - 20 - (pct * (h - 40)); + ctx.beginPath(); + ctx.moveTo(40, y); + ctx.lineTo(w, y); + ctx.stroke(); + + ctx.fillStyle = textColor; + ctx.font = '10px sans-serif'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + ctx.fillText(`${Math.round(pct * 100)}%`, 35, y); + }); + + // Plot each series + const dataMap = metricsData(); + + Object.entries(metricConfigs).forEach(([metricId, config]) => { + const points = dataMap[metricId]; + if (!points || points.length === 0) { + console.log(`[UnifiedHistoryChart] No points for ${metricId}`); + return; + } + + const startTime = points[0].timestamp; + const endTime = points[points.length - 1].timestamp; + const timeSpan = endTime - startTime || 1; + + const getX = (ts: number) => 40 + ((ts - startTime) / timeSpan) * (w - 40); + const getY = (val: number) => h - 20 - (Math.min(Math.max(val, 0), 100) / 100) * (h - 40); + + // Draw Area (Transparent) + ctx.fillStyle = `${config.color}15`; // 15 order opacity + ctx.beginPath(); + ctx.moveTo(getX(points[0].timestamp), h - 20); + points.forEach(p => ctx.lineTo(getX(p.timestamp), getY(p.value))); + ctx.lineTo(getX(points[points.length - 1].timestamp), h - 20); + ctx.fill(); + + // Draw Line + ctx.strokeStyle = config.color; + ctx.lineWidth = 2; + ctx.lineJoin = 'round'; + ctx.beginPath(); + points.forEach((p, i) => { + if (i === 0) ctx.moveTo(getX(p.timestamp), getY(p.value)); + else ctx.lineTo(getX(p.timestamp), getY(p.value)); + }); + ctx.stroke(); + }); + }; + + createEffect(() => { + metricsData(); + drawChart(); + }); + + createEffect(() => { + if (!containerRef) return; + const ro = new ResizeObserver(() => drawChart()); + ro.observe(containerRef); + onCleanup(() => ro.disconnect()); + }); + + const handleMouseMove = (e: MouseEvent) => { + if (!canvasRef) return; + const rect = canvasRef.getBoundingClientRect(); + const x = e.clientX - rect.left; + if (x < 40) { + setHoveredPoint(null); + return; + } + + const dataMap = metricsData(); + const firstMetric = Object.keys(dataMap)[0]; + const points = dataMap[firstMetric]; + if (!points || points.length === 0) return; + + const startTime = points[0].timestamp; + const endTime = points[points.length - 1].timestamp; + const ratio = (x - 40) / (rect.width - 40); + const hoverTs = startTime + ratio * (endTime - startTime); + + const hoverInfo: HoverInfo = { + timestamp: 0, + x: e.clientX, + y: rect.top + 20, + metrics: {} + }; + + let pickedTs = 0; + + Object.entries(metricConfigs).forEach(([id, config]) => { + const pArr = dataMap[id]; + if (!pArr || pArr.length === 0) return; + + let closest = pArr[0]; + let minDiff = Math.abs(pArr[0].timestamp - hoverTs); + for (const p of pArr) { + const diff = Math.abs(p.timestamp - hoverTs); + if (diff < minDiff) { + minDiff = diff; + closest = p; + } + } + pickedTs = closest.timestamp; + hoverInfo.metrics[id] = { + value: closest.value, + min: closest.min || closest.value, + max: closest.max || closest.value, + color: config.color, + label: config.label, + unit: config.unit + }; + }); + + hoverInfo.timestamp = pickedTs; + setHoveredPoint(hoverInfo); + }; + + const updateRange = (r: HistoryTimeRange) => { + setRange(r); + if (props.onRangeChange) props.onRangeChange(r); + }; + + const isLocked = () => (range() === '30d' || range() === '90d') && !hasFeature('long_term_metrics'); + + return ( +
+
+
+ {props.label || 'Unified History'} +
+ + {(c) => ( +
+
+ {c.label} +
+ )} + +
+
+ + +
+ {(['24h', '7d', '30d', '90d'] as HistoryTimeRange[]).map(r => ( + + ))} +
+
+
+ +
+ setHoveredPoint(null)} + /> + + +
+
+
+ + + +
+

{error()}

+
+
+ + +
+
+ + + + +
+

90-Day History Locked

+ Upgrade to Pro +
+
+
+ + + + {(info) => ( +
+
{new Date(info().timestamp).toLocaleString()}
+
+ + {([_, m]) => ( +
+
+
+ {m.label} +
+ + {m.unit === '%' ? `${m.value.toFixed(1)}%` : formatBytes(m.value)} + +
+ )} + +
+
+ )} + + +
+ ); +}; diff --git a/internal/ai/tools/tools_query_test.go b/internal/ai/tools/tools_query_test.go index 9a0a7517b..cce1310dd 100644 --- a/internal/ai/tools/tools_query_test.go +++ b/internal/ai/tools/tools_query_test.go @@ -1,269 +1,314 @@ package tools import ( -"context" -"encoding/json" -"net/http" -"net/http/httptest" -"testing" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" -"github.com/rcourtman/pulse-go-rewrite/internal/agentexec" -"github.com/rcourtman/pulse-go-rewrite/internal/models" -"github.com/stretchr/testify/mock" + "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" + "github.com/rcourtman/pulse-go-rewrite/internal/models" ) func TestExecuteGetCapabilities(t *testing.T) { -stateProv := &mockStateProvider{} -agentSrv := &mockAgentServer{} -agentSrv.On("GetConnectedAgents").Return([]agentexec.ConnectedAgent{ -ame: "host1", Version: "1.0", Platform: "linux"}, -}) -executor := NewPulseToolExecutor(ExecutorConfig{ -tServer: agentSrv, -&mockMetricsHistoryProvider{}, -eProvider: &BaselineMCPAdapter{}, -Provider: &PatternMCPAdapter{}, -&mockAlertProvider{}, -dingsProvider: &mockFindingsProvider{}, -trolLevel: ControlLevelControlled, -g{"100"}, -}) + executor := NewPulseToolExecutor(ExecutorConfig{ + StateProvider: &mockStateProvider{}, + AgentServer: &mockAgentServer{ + agents: []agentexec.ConnectedAgent{ + {Hostname: "host1", Version: "1.0", Platform: "linux"}, + }, + }, + MetricsHistory: &mockMetricsHistoryProvider{}, + BaselineProvider: &BaselineMCPAdapter{}, + PatternProvider: &PatternMCPAdapter{}, + AlertProvider: &mockAlertProvider{}, + FindingsProvider: &mockFindingsProvider{}, + ControlLevel: ControlLevelControlled, + ProtectedGuests: []string{"100"}, + }) -result, err := executor.executeGetCapabilities(context.Background()) -if err != nil { -expected error: %v", err) -} + result, err := executor.executeGetCapabilities(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } -var response CapabilitiesResponse -if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil { -se: %v", err) -} -if response.ControlLevel != string(ControlLevelControlled) || response.ConnectedAgents != 1 { -expected response: %+v", response) -} -if !response.Features.Control || !response.Features.MetricsHistory { -expected features: %+v", response.Features) -} + var response CapabilitiesResponse + if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.ControlLevel != string(ControlLevelControlled) || response.ConnectedAgents != 1 { + t.Fatalf("unexpected response: %+v", response) + } + if !response.Features.Control || !response.Features.MetricsHistory { + t.Fatalf("unexpected features: %+v", response.Features) + } } func TestExecuteGetURLContent(t *testing.T) { - t.Setenv("PULSE_AI_ALLOW_LOOPBACK", "true") - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -:= NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{}}) + w.Header().Set("X-Test", "ok") + w.WriteHeader(http.StatusOK) + w.Write([]byte("hello")) + })) + defer server.Close() -if result, _ := executor.executeGetURLContent(context.Background(), map[string]interface{}{}); !result.IsError { - url missing") -} + executor := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{}}) -result, err := executor.executeGetURLContent(context.Background(), map[string]interface{}{ -nil { -expected error: %v", err) -} -var response URLFetchResponse -if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil { -se: %v", err) -} -if response.StatusCode != http.StatusOK || response.Headers["X-Test"] != "ok" { -expected response: %+v", response) -} + if result, _ := executor.executeGetURLContent(context.Background(), map[string]interface{}{}); !result.IsError { + t.Fatal("expected error when url missing") + } + + result, err := executor.executeGetURLContent(context.Background(), map[string]interface{}{ + "url": server.URL, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var response URLFetchResponse + if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.StatusCode != http.StatusOK || response.Headers["X-Test"] != "ok" { + t.Fatalf("unexpected response: %+v", response) + } } func TestExecuteListInfrastructureAndTopology(t *testing.T) { -state := models.StateSnapshot{ -odes: []models.Node{{ID: "node1", Name: "node1", Status: "online"}}, -ame: "vm1", VMID: 100, Status: "running", Node: "node1"}, -tainers: []models.Container{ -ame: "ct1", VMID: 200, Status: "stopped", Node: "node1"}, - "host1", -ame: "h1", -ame: "Host 1", -tainers: []models.DockerContainer{ -ame: "nginx", State: "running", Image: "nginx"}, -("GetState").Return(state) -agentSrv := &mockAgentServer{} -agentSrv.On("GetConnectedAgents").Return([]agentexec.ConnectedAgent{{Hostname: "node1"}}) + state := models.StateSnapshot{ + Nodes: []models.Node{{ID: "node1", Name: "node1", Status: "online"}}, + VMs: []models.VM{ + {Name: "vm1", VMID: 100, Status: "running", Node: "node1"}, + }, + Containers: []models.Container{ + {Name: "ct1", VMID: 200, Status: "stopped", Node: "node1"}, + }, + DockerHosts: []models.DockerHost{ + { + ID: "host1", + Hostname: "h1", + DisplayName: "Host 1", + Containers: []models.DockerContainer{ + {ID: "c1", Name: "nginx", State: "running", Image: "nginx"}, + }, + }, + }, + } -executor := NewPulseToolExecutor(ExecutorConfig{ -tServer: agentSrv, -trolLevel: ControlLevelControlled, -}) + executor := NewPulseToolExecutor(ExecutorConfig{ + StateProvider: &mockStateProvider{state: state}, + AgentServer: &mockAgentServer{ + agents: []agentexec.ConnectedAgent{{Hostname: "node1"}}, + }, + ControlLevel: ControlLevelControlled, + }) -result, err := executor.executeListInfrastructure(context.Background(), map[string]interface{}{ -"vms", -ning", -}) -if err != nil { -expected error: %v", err) -} -var infra InfrastructureResponse -if err := json.Unmarshal([]byte(result.Content[0].Text), &infra); err != nil { -fra: %v", err) -} -if len(infra.VMs) != 1 || infra.VMs[0].Name != "vm1" { -expected infra response: %+v", infra) -} + result, err := executor.executeListInfrastructure(context.Background(), map[string]interface{}{ + "type": "vms", + "status": "running", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var infra InfrastructureResponse + if err := json.Unmarshal([]byte(result.Content[0].Text), &infra); err != nil { + t.Fatalf("decode infra: %v", err) + } + if len(infra.VMs) != 1 || infra.VMs[0].Name != "vm1" { + t.Fatalf("unexpected infra response: %+v", infra) + } -// Topology includes derived node for VM reference if missing -state.Nodes = nil -stateProv2 := &mockStateProvider{} -stateProv2.On("GetState").Return(state) -executor.stateProvider = stateProv2 -topologyResult, err := executor.executeGetTopology(context.Background(), map[string]interface{}{}) -if err != nil { -expected error: %v", err) -} -var topology TopologyResponse -if err := json.Unmarshal([]byte(topologyResult.Content[0].Text), &topology); err != nil { -err) -} -if topology.Summary.TotalVMs != 1 || len(topology.Proxmox.Nodes) == 0 { -expected topology: %+v", topology) -} + // Topology includes derived node for VM reference if missing + state.Nodes = nil + executor.stateProvider = &mockStateProvider{state: state} + topologyResult, err := executor.executeGetTopology(context.Background(), map[string]interface{}{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var topology TopologyResponse + if err := json.Unmarshal([]byte(topologyResult.Content[0].Text), &topology); err != nil { + t.Fatalf("decode topology: %v", err) + } + if topology.Summary.TotalVMs != 1 || len(topology.Proxmox.Nodes) == 0 { + t.Fatalf("unexpected topology: %+v", topology) + } } func TestExecuteGetTopologySummaryOnly(t *testing.T) { -state := models.StateSnapshot{ -odes: []models.Node{{ID: "node1", Name: "node1", Status: "online"}}, -ame: "vm1", VMID: 100, Status: "running", Node: "node1"}, -tainers: []models.Container{ -ame: "ct1", VMID: 200, Status: "stopped", Node: "node1"}, -ame: "host1", -tainers: []models.DockerContainer{ -ame: "nginx", State: "running", Image: "nginx"}, -("GetState").Return(state) -executor := NewPulseToolExecutor(ExecutorConfig{ -executor.executeGetTopology(context.Background(), map[string]interface{}{ -ly": true, -}) -if err != nil { -expected error: %v", err) -} -var topology TopologyResponse -if err := json.Unmarshal([]byte(result.Content[0].Text), &topology); err != nil { -err) -} -if len(topology.Proxmox.Nodes) != 0 { -o proxmox nodes, got: %+v", topology.Proxmox.Nodes) -} -if len(topology.Docker.Hosts) != 0 { -o docker hosts, got: %+v", topology.Docker.Hosts) -} -if topology.Summary.TotalVMs != 1 || topology.Summary.TotalDockerHosts != 1 || topology.Summary.TotalDockerContainers != 1 { -expected summary: %+v", topology.Summary) -} -if topology.Summary.RunningVMs != 1 || topology.Summary.RunningDocker != 1 { -expected running summary: %+v", topology.Summary) -} + state := models.StateSnapshot{ + Nodes: []models.Node{{ID: "node1", Name: "node1", Status: "online"}}, + VMs: []models.VM{ + {Name: "vm1", VMID: 100, Status: "running", Node: "node1"}, + }, + Containers: []models.Container{ + {Name: "ct1", VMID: 200, Status: "stopped", Node: "node1"}, + }, + DockerHosts: []models.DockerHost{ + { + Hostname: "host1", + Containers: []models.DockerContainer{ + {ID: "c1", Name: "nginx", State: "running", Image: "nginx"}, + }, + }, + }, + } + + executor := NewPulseToolExecutor(ExecutorConfig{ + StateProvider: &mockStateProvider{state: state}, + }) + + result, err := executor.executeGetTopology(context.Background(), map[string]interface{}{ + "summary_only": true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var topology TopologyResponse + if err := json.Unmarshal([]byte(result.Content[0].Text), &topology); err != nil { + t.Fatalf("decode topology: %v", err) + } + if len(topology.Proxmox.Nodes) != 0 { + t.Fatalf("expected no proxmox nodes, got: %+v", topology.Proxmox.Nodes) + } + if len(topology.Docker.Hosts) != 0 { + t.Fatalf("expected no docker hosts, got: %+v", topology.Docker.Hosts) + } + if topology.Summary.TotalVMs != 1 || topology.Summary.TotalDockerHosts != 1 || topology.Summary.TotalDockerContainers != 1 { + t.Fatalf("unexpected summary: %+v", topology.Summary) + } + if topology.Summary.RunningVMs != 1 || topology.Summary.RunningDocker != 1 { + t.Fatalf("unexpected running summary: %+v", topology.Summary) + } } func TestExecuteSearchResources(t *testing.T) { -state := models.StateSnapshot{ -odes: []models.Node{{ID: "node1", Name: "node1", Status: "online"}}, -100, Name: "web-vm", Status: "running", Node: "node1"}, -tainers: []models.Container{ -Name: "db-ct", Status: "stopped", Node: "node1"}, - "host1", -ame: "dock1", -ame: "Dock 1", - "online", -tainers: []models.DockerContainer{ -ame: "nginx", State: "running", Image: "nginx:latest"}, -("GetState").Return(state) -executor := NewPulseToolExecutor(ExecutorConfig{ -executor.executeSearchResources(context.Background(), map[string]interface{}{ -ginx", -}) -if err != nil { -expected error: %v", err) -} -var response ResourceSearchResponse -if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil { -se: %v", err) -} -if len(response.Matches) != 1 || response.Matches[0].Type != "docker" || response.Matches[0].Name != "nginx" { -expected search response: %+v", response) -} + state := models.StateSnapshot{ + Nodes: []models.Node{{ID: "node1", Name: "node1", Status: "online"}}, + VMs: []models.VM{ + {ID: "vm1", VMID: 100, Name: "web-vm", Status: "running", Node: "node1"}, + }, + Containers: []models.Container{ + {ID: "ct1", VMID: 200, Name: "db-ct", Status: "stopped", Node: "node1"}, + }, + DockerHosts: []models.DockerHost{ + { + ID: "host1", + Hostname: "dock1", + DisplayName: "Dock 1", + Status: "online", + Containers: []models.DockerContainer{ + {ID: "c1", Name: "nginx", State: "running", Image: "nginx:latest"}, + }, + }, + }, + } -result, err = executor.executeSearchResources(context.Background(), map[string]interface{}{ - "vm", -}) -if err != nil { -expected error: %v", err) -} -response = ResourceSearchResponse{} -if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil { -se: %v", err) -} -if len(response.Matches) != 1 || response.Matches[0].Type != "vm" || response.Matches[0].Name != "web-vm" { -expected search response: %+v", response) -} + executor := NewPulseToolExecutor(ExecutorConfig{ + StateProvider: &mockStateProvider{state: state}, + }) + + result, err := executor.executeSearchResources(context.Background(), map[string]interface{}{ + "query": "nginx", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var response ResourceSearchResponse + if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(response.Matches) != 1 || response.Matches[0].Type != "docker" || response.Matches[0].Name != "nginx" { + t.Fatalf("unexpected search response: %+v", response) + } + + result, err = executor.executeSearchResources(context.Background(), map[string]interface{}{ + "query": "web", + "type": "vm", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + response = ResourceSearchResponse{} + if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(response.Matches) != 1 || response.Matches[0].Type != "vm" || response.Matches[0].Name != "web-vm" { + t.Fatalf("unexpected search response: %+v", response) + } } func TestExecuteSetResourceURLAndGetResource(t *testing.T) { -executor := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{}}) + executor := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{}}) -if result, _ := executor.executeSetResourceURL(context.Background(), map[string]interface{}{}); !result.IsError { - resource_type missing") -} + if result, _ := executor.executeSetResourceURL(context.Background(), map[string]interface{}{}); !result.IsError { + t.Fatal("expected error when resource_type missing") + } -updater := &fakeMetadataUpdater{} -executor.metadataUpdater = updater -result, err := executor.executeSetResourceURL(context.Background(), map[string]interface{}{ - "100", - "http://example", -}) -if err != nil { -expected error: %v", err) -} -if len(updater.resourceArgs) != 3 || updater.resourceArgs[2] != "http://example" { -expected updater args: %+v", updater.resourceArgs) -} -var setResp map[string]interface{} -if err := json.Unmarshal([]byte(result.Content[0].Text), &setResp); err != nil { -se: %v", err) -} -if setResp["action"] != "set" { -expected set response: %+v", setResp) -} + updater := &fakeMetadataUpdater{} + executor.metadataUpdater = updater + result, err := executor.executeSetResourceURL(context.Background(), map[string]interface{}{ + "resource_type": "guest", + "resource_id": "100", + "url": "http://example", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(updater.resourceArgs) != 3 || updater.resourceArgs[2] != "http://example" { + t.Fatalf("unexpected updater args: %+v", updater.resourceArgs) + } + var setResp map[string]interface{} + if err := json.Unmarshal([]byte(result.Content[0].Text), &setResp); err != nil { + t.Fatalf("decode set response: %v", err) + } + if setResp["action"] != "set" { + t.Fatalf("unexpected set response: %+v", setResp) + } -state := models.StateSnapshot{ - []models.VM{{ID: "vm1", VMID: 100, Name: "vm1", Status: "running", Node: "node1"}}, -tainers: []models.Container{{ID: "ct1", VMID: 200, Name: "ct1", Status: "running", Node: "node1"}}, -ame: "host", -tainers: []models.DockerContainer{{ -"abc123", -ame: "nginx", -ning", -ginx", -("GetState").Return(state) -executor.stateProvider = stateProv + state := models.StateSnapshot{ + VMs: []models.VM{{ID: "vm1", VMID: 100, Name: "vm1", Status: "running", Node: "node1"}}, + Containers: []models.Container{{ID: "ct1", VMID: 200, Name: "ct1", Status: "running", Node: "node1"}}, + DockerHosts: []models.DockerHost{{ + Hostname: "host", + Containers: []models.DockerContainer{{ + ID: "abc123", + Name: "nginx", + State: "running", + Image: "nginx", + }}, + }}, + } + executor.stateProvider = &mockStateProvider{state: state} -resource, _ := executor.executeGetResource(context.Background(), map[string]interface{}{ - "100", -}) -var res ResourceResponse -if err := json.Unmarshal([]byte(resource.Content[0].Text), &res); err != nil { -res.Type != "vm" || res.Name != "vm1" { -expected resource: %+v", res) -} + resource, _ := executor.executeGetResource(context.Background(), map[string]interface{}{ + "resource_type": "vm", + "resource_id": "100", + }) + var res ResourceResponse + if err := json.Unmarshal([]byte(resource.Content[0].Text), &res); err != nil { + t.Fatalf("decode resource: %v", err) + } + if res.Type != "vm" || res.Name != "vm1" { + t.Fatalf("unexpected resource: %+v", res) + } -resource, _ = executor.executeGetResource(context.Background(), map[string]interface{}{ - "abc", -}) -if err := json.Unmarshal([]byte(resource.Content[0].Text), &res); err != nil { -err) -} -if res.Type != "docker" || res.Name != "nginx" { -expected docker resource: %+v", res) -} + resource, _ = executor.executeGetResource(context.Background(), map[string]interface{}{ + "resource_type": "docker", + "resource_id": "abc", + }) + if err := json.Unmarshal([]byte(resource.Content[0].Text), &res); err != nil { + t.Fatalf("decode docker resource: %v", err) + } + if res.Type != "docker" || res.Name != "nginx" { + t.Fatalf("unexpected docker resource: %+v", res) + } } func TestIntArg(t *testing.T) { -if got := intArg(map[string]interface{}{}, "limit", 10); got != 10 { -expected default: %d", got) -} -if got := intArg(map[string]interface{}{"limit": float64(5)}, "limit", 10); got != 5 { -expected value: %d", got) -} + if got := intArg(map[string]interface{}{}, "limit", 10); got != 10 { + t.Fatalf("unexpected default: %d", got) + } + if got := intArg(map[string]interface{}{"limit": float64(5)}, "limit", 10); got != 5 { + t.Fatalf("unexpected value: %d", got) + } }