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:
rcourtman 2026-01-22 00:45:59 +00:00
parent 2e0da42a81
commit 6e2cae2363
3 changed files with 1017 additions and 229 deletions

View 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>
);
};

View 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>
);
};

View file

@ -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)
}
}