mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-12 05:45:27 +00:00
feat(ui): add history chart components for guest drawer
- HistoryChart: single metric visualization (CPU, memory, disk) - UnifiedHistoryChart: combined multi-metric view - Support for time range selection (1h to 90d) - Responsive charts with proper dark mode support - Fix corrupted tools_query_test.go from stash merge
This commit is contained in:
parent
2e0da42a81
commit
6e2cae2363
3 changed files with 1017 additions and 229 deletions
409
frontend-modern/src/components/shared/HistoryChart.tsx
Normal file
409
frontend-modern/src/components/shared/HistoryChart.tsx
Normal file
|
|
@ -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<HistoryChartProps> = (props) => {
|
||||
let canvasRef: HTMLCanvasElement | undefined;
|
||||
let containerRef: HTMLDivElement | undefined;
|
||||
|
||||
const [range, setRange] = createSignal<HistoryTimeRange>(props.range || '24h');
|
||||
const [data, setData] = createSignal<AggregatedMetricPoint[]>([]);
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(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 (
|
||||
<div class="flex flex-col h-full bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">{props.label || 'History'}</span>
|
||||
<Show when={props.unit}>
|
||||
<span class="text-xs text-gray-400">({props.unit})</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Time Range Selector */}
|
||||
<Show when={!props.hideSelector}>
|
||||
<div class="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-0.5">
|
||||
{ranges.map(r => (
|
||||
<button
|
||||
onClick={() => updateRange(r)}
|
||||
class={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${range() === r
|
||||
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="relative flex-1 min-h-[200px] w-full" ref={containerRef}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
class="block w-full h-full cursor-crosshair"
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
|
||||
{/* Empty State */}
|
||||
<Show when={!loading() && data().length === 0 && !error()}>
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-gray-800/50">
|
||||
<div class="text-center">
|
||||
<div class="text-gray-400 mb-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mx-auto">
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
|
||||
<path d="M16 16l5 5" />
|
||||
<path d="M21 21v-5h-5" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">Collecting data... History will appear here.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Loading State */}
|
||||
<Show when={loading()}>
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-gray-800/50 backdrop-blur-[1px]">
|
||||
<div class="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Error State */}
|
||||
<Show when={error()}>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<p class="text-sm text-red-500">{error()}</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Pro Lock Overlay */}
|
||||
<Show when={isLocked()}>
|
||||
<div class="absolute inset-0 z-10 flex flex-col items-center justify-center backdrop-blur-sm bg-white/60 dark:bg-gray-900/60 rounded-lg">
|
||||
<div class="bg-gradient-to-br from-indigo-500 to-purple-600 rounded-full p-3 shadow-lg mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-1">90-Day History</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 text-center max-w-[200px] mb-4">
|
||||
Upgrade to Pulse Pro to unlock 90 days of historical data retention.
|
||||
</p>
|
||||
<a
|
||||
href="https://pulserelay.pro/pricing"
|
||||
target="_blank"
|
||||
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-md shadow-sm transition-colors"
|
||||
>
|
||||
Unlock Pro Features
|
||||
</a>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Portal>
|
||||
<Show when={hoveredPoint()}>
|
||||
{(point) => (
|
||||
<div
|
||||
class="fixed pointer-events-none bg-gray-900 dark:bg-gray-800 text-white text-xs rounded px-2 py-1 shadow-lg border border-gray-700 z-[9999]"
|
||||
style={{
|
||||
left: `${point().x}px`,
|
||||
top: `${point().y}px`,
|
||||
transform: 'translateX(-50%)' // Center
|
||||
}}
|
||||
>
|
||||
<div class="font-medium text-center mb-0.5">{new Date(point().timestamp).toLocaleString()}</div>
|
||||
<div class="text-gray-300">
|
||||
{props.unit === '%' ?
|
||||
`${point().value.toFixed(1)}%` :
|
||||
formatBytes(point().value)}
|
||||
</div>
|
||||
<Show when={point().min !== point().value}>
|
||||
<div class="text-[10px] text-gray-400 mt-0.5">
|
||||
Min: {props.unit === '%' ? point().min.toFixed(1) : formatBytes(point().min)} •
|
||||
Max: {props.unit === '%' ? point().max.toFixed(1) : formatBytes(point().max)}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</Portal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
334
frontend-modern/src/components/shared/UnifiedHistoryChart.tsx
Normal file
334
frontend-modern/src/components/shared/UnifiedHistoryChart.tsx
Normal file
|
|
@ -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<string, { value: number; min: number; max: number; color: string; label: string; unit: string }>;
|
||||
}
|
||||
|
||||
export const UnifiedHistoryChart: Component<UnifiedHistoryChartProps> = (props) => {
|
||||
let canvasRef: HTMLCanvasElement | undefined;
|
||||
let containerRef: HTMLDivElement | undefined;
|
||||
|
||||
const [range, setRange] = createSignal<HistoryTimeRange>(props.range || '24h');
|
||||
const [metricsData, setMetricsData] = createSignal<Record<string, AggregatedMetricPoint[]>>({});
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [hoveredPoint, setHoveredPoint] = createSignal<HoverInfo | null>(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 (
|
||||
<div class="flex flex-col bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-bold text-gray-700 dark:text-gray-200">{props.label || 'Unified History'}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<For each={Object.values(metricConfigs)}>
|
||||
{(c) => (
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="w-2 h-2 rounded-full" style={{ 'background-color': c.color }} />
|
||||
<span class="text-[10px] text-gray-500">{c.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={!props.hideSelector}>
|
||||
<div class="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-0.5">
|
||||
{(['24h', '7d', '30d', '90d'] as HistoryTimeRange[]).map(r => (
|
||||
<button
|
||||
onClick={() => updateRange(r)}
|
||||
class={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${range() === r
|
||||
? 'bg-white dark:bg-gray-600 text-blue-600 dark:text-blue-400 shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="relative flex-1 min-h-[220px] w-full" ref={containerRef}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
class="block w-full h-full cursor-crosshair"
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={() => setHoveredPoint(null)}
|
||||
/>
|
||||
|
||||
<Show when={loading()}>
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm">
|
||||
<div class="w-8 h-8 border-3 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={error()}>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<p class="text-sm text-red-500 bg-red-50 dark:bg-red-900/20 px-3 py-1 rounded-full border border-red-100 dark:border-red-800">{error()}</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={isLocked()}>
|
||||
<div class="absolute inset-0 z-10 flex flex-col items-center justify-center backdrop-blur-md bg-white/40 dark:bg-gray-900/40 rounded-lg">
|
||||
<div class="bg-indigo-600 rounded-full p-2.5 shadow-xl mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white mb-3 text-center">90-Day History Locked</h3>
|
||||
<a href="https://pulserelay.pro/pricing" target="_blank" class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-bold rounded-full shadow-lg transition-all transform hover:scale-105">Upgrade to Pro</a>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Portal>
|
||||
<Show when={hoveredPoint()}>
|
||||
{(info) => (
|
||||
<div
|
||||
class="fixed pointer-events-none bg-gray-900/95 dark:bg-gray-800/95 text-white text-xs rounded-lg px-3 py-2 shadow-2xl border border-gray-700/50 z-[9999] backdrop-blur-md"
|
||||
style={{
|
||||
left: `${info().x}px`,
|
||||
top: `${info().y}px`,
|
||||
transform: 'translateX(-50%)'
|
||||
}}
|
||||
>
|
||||
<div class="font-bold text-center border-b border-gray-700 pb-1.5 mb-1.5 opacity-80">{new Date(info().timestamp).toLocaleString()}</div>
|
||||
<div class="space-y-1.5">
|
||||
<For each={Object.entries(info().metrics)}>
|
||||
{([_, m]) => (
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-1.5 h-1.5 rounded-full" style={{ 'background-color': m.color }} />
|
||||
<span class="opacity-70">{m.label}</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold" style={{ color: m.color }}>
|
||||
{m.unit === '%' ? `${m.value.toFixed(1)}%` : formatBytes(m.value)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</Portal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue