mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-25 12:52:25 +00:00
Split history chart runtime owners
This commit is contained in:
@@ -187,6 +187,14 @@ and `frontend-modern/src/components/shared/interactiveSparklineModel.ts` owns
|
||||
sparkline downsampling, gap segmentation, axis-tick math, and hover-selection
|
||||
policy. Future sparkline work should extend those owners instead of pushing
|
||||
canvas scheduling or chart-shape math back into the shared component shell.
|
||||
The shared history chart now follows the same owner shape.
|
||||
`frontend-modern/src/components/shared/HistoryChart.tsx` stays the render
|
||||
shell, `frontend-modern/src/components/shared/useHistoryChartState.ts` owns
|
||||
license gating, trial actions, history fetch/refresh, canvas draw lifecycle,
|
||||
and hover state, and `frontend-modern/src/components/shared/historyChartModel.ts`
|
||||
owns tooltip formatting, scale and axis math, and closest-point selection.
|
||||
Future history-chart work should extend those owners instead of pushing fetch,
|
||||
license, or canvas math back into the shared component shell.
|
||||
|
||||
The audit log settings surface now follows that same owner split.
|
||||
`frontend-modern/src/components/Settings/AuditLogPanel.tsx` stays the canonical
|
||||
|
||||
@@ -2193,6 +2193,7 @@
|
||||
"exact_files": [
|
||||
"frontend-modern/src/components/shared/__tests__/CommandPaletteModal.test.tsx",
|
||||
"frontend-modern/src/components/shared/__tests__/Dialog.test.tsx",
|
||||
"frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx",
|
||||
"frontend-modern/src/components/shared/__tests__/InfrastructureSummaryTable.test.tsx",
|
||||
"frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx",
|
||||
"frontend-modern/src/components/shared/__tests__/SearchField.test.tsx",
|
||||
|
||||
@@ -1,599 +1,19 @@
|
||||
/**
|
||||
* HistoryChart Component
|
||||
*
|
||||
* Canvas-based chart for displaying historical metrics data (up to 90 days).
|
||||
* Includes user-friendly empty states and Pro-tier gating for 30d/90d data.
|
||||
*/
|
||||
|
||||
import {
|
||||
Component,
|
||||
createEffect,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
Show,
|
||||
createMemo,
|
||||
onMount,
|
||||
} from 'solid-js';
|
||||
import {
|
||||
ChartsAPI,
|
||||
type ResourceType,
|
||||
type HistoryTimeRange,
|
||||
type AggregatedMetricPoint,
|
||||
} from '@/api/charts';
|
||||
import {
|
||||
getUpgradeActionUrlOrFallback,
|
||||
isRangeLocked,
|
||||
licenseStatus,
|
||||
loadLicenseStatus,
|
||||
maxHistoryDays,
|
||||
startProTrial,
|
||||
} from '@/stores/license';
|
||||
import { Component, Show } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
import { calculateOptimalPoints } from '@/utils/downsample';
|
||||
import { setupCanvasDPR } from '@/utils/canvasRenderQueue';
|
||||
import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import {
|
||||
getProTrialStartedMessage,
|
||||
getTrialAlreadyUsedMessage,
|
||||
getTrialStartErrorMessage,
|
||||
getTrialTryAgainLaterMessage,
|
||||
} from '@/utils/upgradePresentation';
|
||||
import { formatHistoryChartTooltipValue, type HistoryChartProps } from './historyChartModel';
|
||||
import { useHistoryChartState } from './useHistoryChartState';
|
||||
|
||||
/** Format a tooltip value according to the metric unit. */
|
||||
function formatTooltipValue(value: number, unit?: string): string {
|
||||
if (unit === '%') return `${value.toFixed(1)}%`;
|
||||
if (unit === 'B/s') return `${formatBytes(value)}/s`;
|
||||
if (unit === 'C') return `${Math.round(value)}°C`;
|
||||
// If no unit or unrecognized, try byte formatting for large values, plain number for small
|
||||
if (!unit) return formatBytes(value);
|
||||
// Generic numeric with unit suffix
|
||||
return `${Number.isInteger(value) ? value : value.toFixed(1)} ${unit}`;
|
||||
}
|
||||
|
||||
interface HistoryChartProps {
|
||||
resourceType: ResourceType;
|
||||
resourceId: string;
|
||||
metric: string;
|
||||
height?: number;
|
||||
color?: string;
|
||||
label?: string;
|
||||
unit?: string;
|
||||
range?: HistoryTimeRange;
|
||||
onRangeChange?: (range: HistoryTimeRange) => void;
|
||||
hideSelector?: boolean;
|
||||
/** Strip outer card chrome (border/padding/shadow) and reduce min-height for inline embedding. */
|
||||
compact?: boolean;
|
||||
/** Suppress the built-in Pro lock overlay (caller handles it externally). */
|
||||
hideLock?: boolean;
|
||||
/** Direct data injection (bypasses API fetch) */
|
||||
data?: AggregatedMetricPoint[];
|
||||
}
|
||||
export type { HistoryChartProps } from './historyChartModel';
|
||||
|
||||
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);
|
||||
const [source, setSource] = createSignal<'store' | 'memory' | 'live' | null>(null);
|
||||
const [maxPoints, setMaxPoints] = createSignal<number | null>(null);
|
||||
const [refreshTick, setRefreshTick] = createSignal(0);
|
||||
// Track if we have ever loaded data successfully - used to determine if we show loading spinner
|
||||
const [hasLoadedOnce, setHasLoadedOnce] = createSignal(false);
|
||||
// Track cursor X position for crosshair line (null when not hovering)
|
||||
const [cursorX, setCursorX] = createSignal<number | null>(null);
|
||||
const [startingTrial, setStartingTrial] = createSignal(false);
|
||||
|
||||
const canStartTrial = createMemo(() => {
|
||||
const state = licenseStatus()?.subscription_state;
|
||||
if (!state) return false;
|
||||
return state !== 'active' && state !== 'trial';
|
||||
const chart = useHistoryChartState(props, {
|
||||
getCanvas: () => canvasRef,
|
||||
getContainer: () => containerRef,
|
||||
});
|
||||
|
||||
const handleStartTrial = async () => {
|
||||
if (startingTrial()) return;
|
||||
setStartingTrial(true);
|
||||
try {
|
||||
const result = await startProTrial();
|
||||
if (result?.outcome === 'redirect') {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = result.actionUrl;
|
||||
}
|
||||
return;
|
||||
}
|
||||
notificationStore.success(getProTrialStartedMessage());
|
||||
} catch (err) {
|
||||
const statusCode = (err as { status?: number } | null)?.status;
|
||||
if (statusCode === 409) {
|
||||
notificationStore.error(getTrialAlreadyUsedMessage());
|
||||
} else if (statusCode === 429) {
|
||||
notificationStore.error(getTrialTryAgainLaterMessage());
|
||||
} else {
|
||||
notificationStore.error(
|
||||
getTrialStartErrorMessage(err instanceof Error ? err.message : undefined, {
|
||||
branded: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setStartingTrial(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshIntervalMs = createMemo(() => {
|
||||
const r = range();
|
||||
switch (r) {
|
||||
case '7d':
|
||||
return 30000;
|
||||
case '30d':
|
||||
return 60000;
|
||||
case '90d':
|
||||
return 120000;
|
||||
default:
|
||||
return 10000;
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
|
||||
// Sync external data if provided
|
||||
createEffect(() => {
|
||||
if (props.data) {
|
||||
setData(props.data);
|
||||
if (!hasLoadedOnce()) setHasLoadedOnce(true);
|
||||
setSource('live');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle range change
|
||||
const updateRange = (newRange: HistoryTimeRange) => {
|
||||
setRange(newRange);
|
||||
if (props.onRangeChange) {
|
||||
props.onRangeChange(newRange);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if current view is locked (uses shared gating logic from license store)
|
||||
const isLocked = createMemo(() => isRangeLocked(range()));
|
||||
|
||||
const lockDays = createMemo(() => (range() === '30d' ? '30' : '90'));
|
||||
|
||||
const lockTierLabel = createMemo(() => {
|
||||
const max = maxHistoryDays();
|
||||
const targetDays = range() === '30d' ? 30 : range() === '90d' ? 90 : 14;
|
||||
if (max <= 7 && targetDays <= 14) return 'Relay';
|
||||
return 'Pro';
|
||||
});
|
||||
|
||||
createEffect((wasLockOverlayVisible) => {
|
||||
const lockOverlayVisible = isLocked() && !props.hideLock;
|
||||
if (lockOverlayVisible && !wasLockOverlayVisible) {
|
||||
trackPaywallViewed('long_term_metrics', 'history_chart');
|
||||
}
|
||||
return lockOverlayVisible;
|
||||
}, false);
|
||||
|
||||
// Hover state for tooltip
|
||||
const [hoveredPoint, setHoveredPoint] = createSignal<{
|
||||
value: number;
|
||||
timestamp: number;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
// Compute overall min/max from visible data for persistent header display
|
||||
const dataMin = createMemo(() => {
|
||||
const points = data();
|
||||
if (points.length === 0) return null;
|
||||
let min = Infinity;
|
||||
for (const p of points) {
|
||||
const v = p.min != null ? p.min : p.value;
|
||||
if (v < min) min = v;
|
||||
}
|
||||
return min;
|
||||
});
|
||||
|
||||
const dataMax = createMemo(() => {
|
||||
const points = data();
|
||||
if (points.length === 0) return null;
|
||||
let max = -Infinity;
|
||||
for (const p of points) {
|
||||
const v = p.max != null ? p.max : p.value;
|
||||
if (v > max) max = v;
|
||||
}
|
||||
return max;
|
||||
});
|
||||
|
||||
// Helper function to load data
|
||||
const loadData = async (
|
||||
r: HistoryTimeRange,
|
||||
type: ResourceType,
|
||||
id: string,
|
||||
metric: string,
|
||||
pointsCap: number | null,
|
||||
isBackgroundRefresh: boolean,
|
||||
) => {
|
||||
// Only show loading spinner if this is NOT a background refresh and we haven't loaded once yet
|
||||
if (!isBackgroundRefresh && !hasLoadedOnce()) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
// Don't clear source during background refresh to avoid flashing UI changes
|
||||
if (!isBackgroundRefresh) {
|
||||
setSource(null);
|
||||
}
|
||||
try {
|
||||
const result = await ChartsAPI.getMetricsHistory({
|
||||
resourceType: type,
|
||||
resourceId: id,
|
||||
metric: metric,
|
||||
range: r,
|
||||
maxPoints: pointsCap ?? undefined,
|
||||
});
|
||||
|
||||
if ('points' in result) {
|
||||
setData(result.points || []);
|
||||
setSource(result.source ?? 'store');
|
||||
} else {
|
||||
// Should not happen as we request single metric
|
||||
setData([]);
|
||||
setSource(result.source ?? 'store');
|
||||
}
|
||||
// Mark that we've successfully loaded data at least once
|
||||
if (!hasLoadedOnce()) {
|
||||
setHasLoadedOnce(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch metrics history:', err);
|
||||
// Only show error if we don't have data already
|
||||
if (!hasLoadedOnce()) {
|
||||
setError('Failed to load history data');
|
||||
}
|
||||
setSource(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Main data loading effect - responds to resource/range changes
|
||||
// This is NOT a background refresh, so show loading spinner on first load
|
||||
createEffect(async () => {
|
||||
// Skip fetch if external data is provided
|
||||
if (props.data) return;
|
||||
|
||||
const r = range();
|
||||
const type = props.resourceType;
|
||||
const id = props.resourceId;
|
||||
const metric = props.metric;
|
||||
const locked = isLocked();
|
||||
const pointsCap = maxPoints();
|
||||
|
||||
if (!id || !type) return;
|
||||
|
||||
if (locked) {
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setSource(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Initial or user-triggered load (not background refresh)
|
||||
loadData(r, type, id, metric, pointsCap, false);
|
||||
});
|
||||
|
||||
// Separate effect for background refresh - uses refreshTick only
|
||||
// This ensures background refreshes are silent (no loading spinner)
|
||||
createEffect(() => {
|
||||
const tick = refreshTick();
|
||||
// Only trigger background refresh after at least one tick (initial is 0)
|
||||
if (tick === 0) return;
|
||||
|
||||
const r = range();
|
||||
const type = props.resourceType;
|
||||
const id = props.resourceId;
|
||||
const metric = props.metric;
|
||||
const locked = isLocked();
|
||||
const pointsCap = maxPoints();
|
||||
|
||||
if (!id || !type || locked) return;
|
||||
|
||||
// Background refresh - pass true to prevent loading spinner
|
||||
loadData(r, type, id, metric, pointsCap, true);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const interval = refreshIntervalMs();
|
||||
if (!interval || interval <= 0) return;
|
||||
const timer = window.setInterval(() => {
|
||||
setRefreshTick((t) => t + 1);
|
||||
}, interval);
|
||||
onCleanup(() => window.clearInterval(timer));
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
setupCanvasDPR(canvas, ctx, 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';
|
||||
const axisTextColor = 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
|
||||
|
||||
// Unit classification (doesn't depend on data)
|
||||
const isPercentLike = props.unit === '%';
|
||||
const isByteLike = !props.unit || props.unit === 'B/s';
|
||||
|
||||
// Calculate scale (needs data for absolute metrics)
|
||||
const minValue = 0;
|
||||
let maxValue = 100; // default for empty/percentage
|
||||
if (points.length > 0) {
|
||||
const rawMax = Math.max(...points.map((p) => p.max || p.value));
|
||||
maxValue = isPercentLike ? Math.max(100, rawMax) : Math.max(1, rawMax * 1.15);
|
||||
}
|
||||
|
||||
// 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 (isPercentLike) {
|
||||
label = pct === 0 ? '0%' : pct === 1 ? '100%' : '50%';
|
||||
} else if (isByteLike) {
|
||||
label = pct === 0 ? '0' : pct === 1 ? 'Max' : 'Avg';
|
||||
} else {
|
||||
// Absolute numeric values (temperature, counters) — show computed scale
|
||||
const scaleVal = Math.round(minValue + pct * (maxValue - minValue));
|
||||
label = pct === 0 ? '0' : `${scaleVal}`;
|
||||
}
|
||||
ctx.fillText(label, 35, y);
|
||||
});
|
||||
|
||||
// If no data or loading
|
||||
if (points.length === 0) {
|
||||
return; // Empty state handled in JSX
|
||||
}
|
||||
|
||||
// Calculate time scale
|
||||
const startTime = points[0].timestamp;
|
||||
const endTime = points[points.length - 1].timestamp;
|
||||
const timeSpan = Math.max(1, endTime - startTime);
|
||||
|
||||
// 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();
|
||||
|
||||
ctx.fillStyle = `${mainColor}66`; // 40% opacity solid fill
|
||||
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();
|
||||
|
||||
// X-axis time labels
|
||||
const formatTimeLabel = (ts: number) => {
|
||||
const date = new Date(ts);
|
||||
const r = range();
|
||||
if (r === '30d' || r === '90d' || r === '7d') {
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
ctx.fillStyle = axisTextColor;
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'bottom';
|
||||
|
||||
const labelCount = 4;
|
||||
for (let i = 0; i < labelCount; i++) {
|
||||
const t = startTime + (timeSpan * i) / (labelCount - 1);
|
||||
const x = getX(t);
|
||||
ctx.fillText(formatTimeLabel(t), x, h - 2);
|
||||
}
|
||||
|
||||
// Draw crosshair vertical line and point circle if cursor is hovering
|
||||
const cursor = cursorX();
|
||||
if (cursor !== null && cursor >= 40 && points.length > 0) {
|
||||
// Draw the vertical dashed line
|
||||
ctx.save();
|
||||
ctx.strokeStyle = isDark ? 'rgba(255, 255, 255, 0.4)' : 'rgba(0, 0, 0, 0.3)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]); // Dotted line pattern
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cursor, 0);
|
||||
ctx.lineTo(cursor, h - 20); // Stop at x-axis labels area
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
// Calculate which point is nearest to the cursor X position
|
||||
const ratio = (cursor - 40) / (w - 40);
|
||||
const hoverTs = startTime + ratio * timeSpan;
|
||||
|
||||
// Find the nearest point
|
||||
let closest = points[0];
|
||||
let minDiff = Math.abs(points[0].timestamp - hoverTs);
|
||||
for (const p of points) {
|
||||
const diff = Math.abs(p.timestamp - hoverTs);
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
closest = p;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the exact position
|
||||
const pointX = getX(closest.timestamp);
|
||||
const pointY = getY(closest.value);
|
||||
|
||||
// Draw outer ring (white/dark background for contrast)
|
||||
ctx.beginPath();
|
||||
ctx.arc(pointX, pointY, 5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = isDark ? '#1f2937' : '#ffffff';
|
||||
ctx.fill();
|
||||
|
||||
// Draw colored circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(pointX, pointY, 4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = mainColor;
|
||||
ctx.fill();
|
||||
|
||||
// Draw inner highlight
|
||||
ctx.beginPath();
|
||||
ctx.arc(pointX, pointY, 2, 0, Math.PI * 2);
|
||||
ctx.fillStyle = isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(255, 255, 255, 0.8)';
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Min/Max envelope (optional, for pro feel?)
|
||||
// Let's keep it clean for now, maybe add later.
|
||||
};
|
||||
|
||||
// Reactivity
|
||||
createEffect(() => {
|
||||
cursorX(); // Track cursor position for crosshair redraw
|
||||
drawChart();
|
||||
});
|
||||
|
||||
// Resize observer
|
||||
createEffect(() => {
|
||||
if (!containerRef) return;
|
||||
|
||||
const updateMaxPoints = () => {
|
||||
const width = containerRef?.clientWidth || 0;
|
||||
if (width <= 0) return;
|
||||
// Use optimized point calculation: ~1 point per 2 pixels
|
||||
const next = calculateOptimalPoints(width, 'history');
|
||||
if (next !== maxPoints()) {
|
||||
setMaxPoints(next);
|
||||
}
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updateMaxPoints();
|
||||
drawChart();
|
||||
});
|
||||
resizeObserver.observe(containerRef);
|
||||
updateMaxPoints();
|
||||
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) {
|
||||
setCursorX(null);
|
||||
setHoveredPoint(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update cursor position for crosshair line
|
||||
setCursorX(x);
|
||||
|
||||
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,
|
||||
timestamp: closest.timestamp,
|
||||
x: rect.left + x,
|
||||
y: rect.top + 20, // Approximate
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoveredPoint(null);
|
||||
setCursorX(null);
|
||||
};
|
||||
|
||||
const ranges: HistoryTimeRange[] = ['24h', '7d', '30d', '90d'];
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`flex flex-col h-full ${props.compact ? '' : 'bg-surface rounded-md shadow-sm border border-border p-4'}`}
|
||||
@@ -604,50 +24,53 @@ export const HistoryChart: Component<HistoryChartProps> = (props) => {
|
||||
<Show when={props.unit}>
|
||||
<span class="text-xs text-slate-400">({props.unit})</span>
|
||||
</Show>
|
||||
<Show when={source() && source() !== 'store'}>
|
||||
<Show when={chart.source() && chart.source() !== 'store'}>
|
||||
<span
|
||||
class={`text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide ${
|
||||
source() === 'live'
|
||||
chart.source() === 'live'
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
|
||||
: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300'
|
||||
}`}
|
||||
title={
|
||||
source() === 'live'
|
||||
chart.source() === 'live'
|
||||
? 'Live sample shown because history is not available yet.'
|
||||
: 'In-memory buffer shown while history is warming up.'
|
||||
}
|
||||
>
|
||||
{source() === 'live' ? 'Live' : 'Buffer'}
|
||||
{chart.source() === 'live' ? 'Live' : 'Buffer'}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<Show when={dataMin() !== null && dataMax() !== null}>
|
||||
<Show when={chart.dataMin() !== null && chart.dataMax() !== null}>
|
||||
<div class="flex items-center gap-2 text-[10px]">
|
||||
<span>
|
||||
<span class="text-muted">Min </span>
|
||||
<span class="text-blue-400">{formatTooltipValue(dataMin()!, props.unit)}</span>
|
||||
<span class="text-blue-400">
|
||||
{formatHistoryChartTooltipValue(chart.dataMin()!, props.unit)}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
<span class="text-muted">Max </span>
|
||||
<span class="text-red-400">{formatTooltipValue(dataMax()!, props.unit)}</span>
|
||||
<span class="text-red-400">
|
||||
{formatHistoryChartTooltipValue(chart.dataMax()!, props.unit)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
{/* Time Range Selector */}
|
||||
<Show when={!props.hideSelector}>
|
||||
<div class="flex bg-surface-hover rounded-md p-0.5">
|
||||
{ranges.map((r) => (
|
||||
{chart.ranges.map((range) => (
|
||||
<button
|
||||
onClick={() => updateRange(r)}
|
||||
onClick={() => chart.updateRange(range)}
|
||||
class={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||
range() === r
|
||||
chart.range() === range
|
||||
? 'bg-surface text-base-content shadow-sm'
|
||||
: 'text-muted hover:text-base-content'
|
||||
}`}
|
||||
>
|
||||
{r}
|
||||
{range}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -662,12 +85,11 @@ export const HistoryChart: Component<HistoryChartProps> = (props) => {
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
class="block w-full h-full cursor-crosshair"
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onMouseMove={chart.handleMouseMove}
|
||||
onMouseLeave={chart.handleMouseLeave}
|
||||
/>
|
||||
|
||||
{/* Empty State */}
|
||||
<Show when={!loading() && data().length === 0 && !error()}>
|
||||
<Show when={!chart.loading() && chart.data().length === 0 && !chart.error()}>
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-surface">
|
||||
<div class="text-center">
|
||||
<div class="text-slate-400 mb-2">
|
||||
@@ -695,22 +117,19 @@ export const HistoryChart: Component<HistoryChartProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Loading State */}
|
||||
<Show when={loading()}>
|
||||
<Show when={chart.loading()}>
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-surface -[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()}>
|
||||
<Show when={chart.error()}>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<p class="text-sm text-red-500">{error()}</p>
|
||||
<p class="text-sm text-red-500">{chart.error()}</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Pro Lock Overlay */}
|
||||
<Show when={isLocked() && !props.hideLock}>
|
||||
<Show when={chart.isLocked() && !props.hideLock}>
|
||||
<div class="absolute inset-0 z-10 flex flex-col items-center justify-center bg-surface rounded-md">
|
||||
<div class="bg-indigo-500 rounded-full p-3 shadow-sm mb-3">
|
||||
<svg
|
||||
@@ -728,26 +147,27 @@ export const HistoryChart: Component<HistoryChartProps> = (props) => {
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-base-content mb-1">{lockDays()}-Day History</h3>
|
||||
<h3 class="text-lg font-bold text-base-content mb-1">{chart.lockDays()}-Day History</h3>
|
||||
<p class="text-sm text-muted text-center max-w-[200px] mb-4">
|
||||
Upgrade to {lockTierLabel()} to unlock {lockDays()} days of historical data retention.
|
||||
Upgrade to {chart.lockTierLabel()} to unlock {chart.lockDays()} days of historical
|
||||
data retention.
|
||||
</p>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<a
|
||||
href={getUpgradeActionUrlOrFallback('long_term_metrics')}
|
||||
href={chart.getUpgradeActionUrlOrFallback('long_term_metrics')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackUpgradeClicked('history_chart', 'long_term_metrics')}
|
||||
onClick={() => chart.trackUpgradeClicked('history_chart', 'long_term_metrics')}
|
||||
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 {lockTierLabel()} Features
|
||||
Unlock {chart.lockTierLabel()} Features
|
||||
</a>
|
||||
<Show when={canStartTrial()}>
|
||||
<Show when={chart.canStartTrial()}>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-semibold text-indigo-700 dark:text-indigo-300 hover:underline disabled:opacity-60"
|
||||
disabled={startingTrial()}
|
||||
onClick={handleStartTrial}
|
||||
disabled={chart.startingTrial()}
|
||||
onClick={chart.handleStartTrial}
|
||||
>
|
||||
Or start a free 14-day trial
|
||||
</button>
|
||||
@@ -758,7 +178,7 @@ export const HistoryChart: Component<HistoryChartProps> = (props) => {
|
||||
</div>
|
||||
|
||||
<Portal>
|
||||
<Show when={hoveredPoint()}>
|
||||
<Show when={chart.hoveredPoint()}>
|
||||
{(point) => (
|
||||
<div
|
||||
class="fixed pointer-events-none text-xs rounded px-2 py-1 shadow-lg border border-slate-600 z-[9999]"
|
||||
@@ -774,7 +194,7 @@ export const HistoryChart: Component<HistoryChartProps> = (props) => {
|
||||
{new Date(point().timestamp).toLocaleString()}
|
||||
</div>
|
||||
<div style={{ color: 'rgb(203, 213, 225)' }}>
|
||||
{formatTooltipValue(point().value, props.unit)}
|
||||
{formatHistoryChartTooltipValue(point().value, props.unit)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import calloutCardSource from '@/components/shared/CalloutCard.tsx?raw';
|
||||
import filterButtonGroupSource from '@/components/shared/FilterButtonGroup.tsx?raw';
|
||||
import historyChartSource from '@/components/shared/HistoryChart.tsx?raw';
|
||||
import historyChartModelSource from '@/components/shared/historyChartModel.ts?raw';
|
||||
import interactiveSparklineSource from '@/components/shared/InteractiveSparkline.tsx?raw';
|
||||
import interactiveSparklineModelSource from '@/components/shared/interactiveSparklineModel.ts?raw';
|
||||
import infrastructureSummaryTableSource from '@/components/shared/InfrastructureSummaryTable.tsx?raw';
|
||||
@@ -10,6 +12,7 @@ import infrastructureSummaryTableStateSource from '@/components/shared/useInfras
|
||||
import monitoredSystemLimitWarningBannerSource from '@/components/shared/MonitoredSystemLimitWarningBanner.tsx?raw';
|
||||
import selectionCardGroupSource from '@/components/shared/SelectionCardGroup.tsx?raw';
|
||||
import tagBadgesSource from '@/components/shared/TagBadges.tsx?raw';
|
||||
import historyChartStateSource from '@/components/shared/useHistoryChartState.ts?raw';
|
||||
import interactiveSparklineStateSource from '@/components/shared/useInteractiveSparklineState.ts?raw';
|
||||
import guestRowSource from '@/components/Dashboard/GuestRow.tsx?raw';
|
||||
import resourceDetailDrawerOverviewSource from '@/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx?raw';
|
||||
@@ -160,4 +163,21 @@ describe('shared primitive guardrails', () => {
|
||||
expect(interactiveSparklineModelSource).toContain('downsampleLTTB');
|
||||
expect(interactiveSparklineModelSource).toContain('findNearestMetricPoint');
|
||||
});
|
||||
|
||||
it('keeps history chart on shell, runtime, and model owners', () => {
|
||||
expect(historyChartSource).toContain('useHistoryChartState');
|
||||
expect(historyChartSource).not.toContain('ChartsAPI.getMetricsHistory');
|
||||
expect(historyChartSource).not.toContain('calculateOptimalPoints');
|
||||
expect(historyChartSource).not.toContain('setupCanvasDPR');
|
||||
expect(historyChartSource).not.toContain('createSignal');
|
||||
|
||||
expect(historyChartStateSource).toContain('ChartsAPI.getMetricsHistory');
|
||||
expect(historyChartStateSource).toContain('calculateOptimalPoints');
|
||||
expect(historyChartStateSource).toContain('setupCanvasDPR');
|
||||
expect(historyChartStateSource).toContain('export function useHistoryChartState');
|
||||
|
||||
expect(historyChartModelSource).toContain('formatHistoryChartTooltipValue');
|
||||
expect(historyChartModelSource).toContain('getHistoryChartScale');
|
||||
expect(historyChartModelSource).toContain('findHistoryChartClosestPoint');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@solidjs/testing-library';
|
||||
import historyChartSource from '@/components/shared/HistoryChart.tsx?raw';
|
||||
import historyChartModelSource from '@/components/shared/historyChartModel.ts?raw';
|
||||
import historyChartStateSource from '@/components/shared/useHistoryChartState.ts?raw';
|
||||
import { HistoryChart } from '@/components/shared/HistoryChart';
|
||||
|
||||
if (typeof globalThis.ResizeObserver === 'undefined') {
|
||||
globalThis.ResizeObserver = class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
}
|
||||
|
||||
HTMLCanvasElement.prototype.getContext = vi.fn(() => ({
|
||||
clearRect: vi.fn(),
|
||||
setTransform: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
setLineDash: vi.fn(),
|
||||
createLinearGradient: vi.fn(() => ({
|
||||
addColorStop: vi.fn(),
|
||||
})),
|
||||
})) as unknown as typeof HTMLCanvasElement.prototype.getContext;
|
||||
|
||||
vi.mock('@/stores/license', () => ({
|
||||
getUpgradeActionUrlOrFallback: () => 'https://example.com/upgrade',
|
||||
isRangeLocked: () => false,
|
||||
licenseStatus: () => ({ subscription_state: 'active' }),
|
||||
loadLicenseStatus: vi.fn(),
|
||||
maxHistoryDays: () => 30,
|
||||
startProTrial: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/charts', () => ({
|
||||
ChartsAPI: {
|
||||
getMetricsHistory: vi.fn().mockResolvedValue({ points: [], source: 'store' }),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('HistoryChart', () => {
|
||||
it('keeps the history chart on shell, runtime, and model owners', () => {
|
||||
expect(historyChartSource).toContain('useHistoryChartState');
|
||||
expect(historyChartSource).not.toContain('ChartsAPI.getMetricsHistory');
|
||||
expect(historyChartSource).not.toContain('calculateOptimalPoints');
|
||||
expect(historyChartSource).not.toContain('setupCanvasDPR');
|
||||
expect(historyChartSource).not.toContain('createSignal');
|
||||
|
||||
expect(historyChartStateSource).toContain('ChartsAPI.getMetricsHistory');
|
||||
expect(historyChartStateSource).toContain('calculateOptimalPoints');
|
||||
expect(historyChartStateSource).toContain('setupCanvasDPR');
|
||||
expect(historyChartStateSource).toContain('export function useHistoryChartState');
|
||||
|
||||
expect(historyChartModelSource).toContain('formatHistoryChartTooltipValue');
|
||||
expect(historyChartModelSource).toContain('getHistoryChartScale');
|
||||
expect(historyChartModelSource).toContain('findHistoryChartClosestPoint');
|
||||
});
|
||||
|
||||
it('renders the default history label', () => {
|
||||
render(() => (
|
||||
<HistoryChart resourceType="node" resourceId="node-1" metric="cpu" />
|
||||
));
|
||||
|
||||
expect(screen.getByText('History')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import type {
|
||||
AggregatedMetricPoint,
|
||||
HistoryTimeRange,
|
||||
ResourceType,
|
||||
} from '@/api/charts';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
|
||||
export interface HistoryChartProps {
|
||||
resourceType: ResourceType;
|
||||
resourceId: string;
|
||||
metric: string;
|
||||
height?: number;
|
||||
color?: string;
|
||||
label?: string;
|
||||
unit?: string;
|
||||
range?: HistoryTimeRange;
|
||||
onRangeChange?: (range: HistoryTimeRange) => void;
|
||||
hideSelector?: boolean;
|
||||
compact?: boolean;
|
||||
hideLock?: boolean;
|
||||
data?: AggregatedMetricPoint[];
|
||||
}
|
||||
|
||||
export interface HistoryChartHoverPoint {
|
||||
value: number;
|
||||
timestamp: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export const HISTORY_CHART_RANGES: HistoryTimeRange[] = ['24h', '7d', '30d', '90d'];
|
||||
|
||||
export function formatHistoryChartTooltipValue(value: number, unit?: string): string {
|
||||
if (unit === '%') return `${value.toFixed(1)}%`;
|
||||
if (unit === 'B/s') return `${formatBytes(value)}/s`;
|
||||
if (unit === 'C') return `${Math.round(value)}°C`;
|
||||
if (!unit) return formatBytes(value);
|
||||
return `${Number.isInteger(value) ? value : value.toFixed(1)} ${unit}`;
|
||||
}
|
||||
|
||||
export function getHistoryChartRefreshIntervalMs(range: HistoryTimeRange) {
|
||||
switch (range) {
|
||||
case '7d':
|
||||
return 30000;
|
||||
case '30d':
|
||||
return 60000;
|
||||
case '90d':
|
||||
return 120000;
|
||||
default:
|
||||
return 10000;
|
||||
}
|
||||
}
|
||||
|
||||
export function getHistoryChartDefaultColor(metric: string, color?: string) {
|
||||
if (color) return color;
|
||||
if (metric === 'cpu') return '#8b5cf6';
|
||||
if (metric === 'memory') return '#f59e0b';
|
||||
if (metric === 'disk') return '#10b981';
|
||||
return '#3b82f6';
|
||||
}
|
||||
|
||||
export function getHistoryChartDataMin(points: AggregatedMetricPoint[]) {
|
||||
if (points.length === 0) return null;
|
||||
let min = Infinity;
|
||||
for (const point of points) {
|
||||
const value = point.min != null ? point.min : point.value;
|
||||
if (value < min) min = value;
|
||||
}
|
||||
return min;
|
||||
}
|
||||
|
||||
export function getHistoryChartDataMax(points: AggregatedMetricPoint[]) {
|
||||
if (points.length === 0) return null;
|
||||
let max = -Infinity;
|
||||
for (const point of points) {
|
||||
const value = point.max != null ? point.max : point.value;
|
||||
if (value > max) max = value;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
export function getHistoryChartScale(points: AggregatedMetricPoint[], unit?: string) {
|
||||
const minValue = 0;
|
||||
const isPercentLike = unit === '%';
|
||||
const isByteLike = !unit || unit === 'B/s';
|
||||
let maxValue = 100;
|
||||
if (points.length > 0) {
|
||||
const rawMax = Math.max(...points.map((point) => point.max || point.value));
|
||||
maxValue = isPercentLike ? Math.max(100, rawMax) : Math.max(1, rawMax * 1.15);
|
||||
}
|
||||
|
||||
return {
|
||||
isPercentLike,
|
||||
isByteLike,
|
||||
minValue,
|
||||
maxValue,
|
||||
};
|
||||
}
|
||||
|
||||
export function getHistoryChartYAxisLabels({
|
||||
minValue,
|
||||
maxValue,
|
||||
isPercentLike,
|
||||
isByteLike,
|
||||
}: {
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
isPercentLike: boolean;
|
||||
isByteLike: boolean;
|
||||
}) {
|
||||
return [0, 0.5, 1].map((pct) => {
|
||||
let label = '';
|
||||
if (isPercentLike) {
|
||||
label = pct === 0 ? '0%' : pct === 1 ? '100%' : '50%';
|
||||
} else if (isByteLike) {
|
||||
label = pct === 0 ? '0' : pct === 1 ? 'Max' : 'Avg';
|
||||
} else {
|
||||
const scaleValue = Math.round(minValue + pct * (maxValue - minValue));
|
||||
label = pct === 0 ? '0' : `${scaleValue}`;
|
||||
}
|
||||
return { pct, label };
|
||||
});
|
||||
}
|
||||
|
||||
export function formatHistoryChartTimeLabel(timestamp: number, range: HistoryTimeRange) {
|
||||
const date = new Date(timestamp);
|
||||
if (range === '30d' || range === '90d' || range === '7d') {
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
export function createHistoryChartGeometry({
|
||||
width,
|
||||
height,
|
||||
startTime,
|
||||
endTime,
|
||||
minValue,
|
||||
maxValue,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
}) {
|
||||
const timeSpan = Math.max(1, endTime - startTime);
|
||||
const getX = (timestamp: number) => 40 + ((timestamp - startTime) / timeSpan) * (width - 40);
|
||||
const getY = (value: number) =>
|
||||
height - 20 - ((value - minValue) / (maxValue - minValue)) * (height - 40);
|
||||
|
||||
return {
|
||||
timeSpan,
|
||||
getX,
|
||||
getY,
|
||||
};
|
||||
}
|
||||
|
||||
export function findHistoryChartClosestPoint(
|
||||
points: AggregatedMetricPoint[],
|
||||
hoverTimestamp: number,
|
||||
) {
|
||||
let closest = points[0];
|
||||
let minDiff = Math.abs(points[0].timestamp - hoverTimestamp);
|
||||
for (const point of points) {
|
||||
const diff = Math.abs(point.timestamp - hoverTimestamp);
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
closest = point;
|
||||
}
|
||||
}
|
||||
return closest;
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
onMount,
|
||||
} from 'solid-js';
|
||||
import { ChartsAPI, type HistoryTimeRange } from '@/api/charts';
|
||||
import {
|
||||
getUpgradeActionUrlOrFallback,
|
||||
isRangeLocked,
|
||||
licenseStatus,
|
||||
loadLicenseStatus,
|
||||
maxHistoryDays,
|
||||
startProTrial,
|
||||
} from '@/stores/license';
|
||||
import { calculateOptimalPoints } from '@/utils/downsample';
|
||||
import { setupCanvasDPR } from '@/utils/canvasRenderQueue';
|
||||
import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import {
|
||||
getProTrialStartedMessage,
|
||||
getTrialAlreadyUsedMessage,
|
||||
getTrialStartErrorMessage,
|
||||
getTrialTryAgainLaterMessage,
|
||||
} from '@/utils/upgradePresentation';
|
||||
import {
|
||||
createHistoryChartGeometry,
|
||||
findHistoryChartClosestPoint,
|
||||
formatHistoryChartTimeLabel,
|
||||
getHistoryChartDataMax,
|
||||
getHistoryChartDataMin,
|
||||
getHistoryChartDefaultColor,
|
||||
getHistoryChartRefreshIntervalMs,
|
||||
getHistoryChartScale,
|
||||
getHistoryChartYAxisLabels,
|
||||
type HistoryChartProps,
|
||||
type HistoryChartHoverPoint,
|
||||
} from './historyChartModel';
|
||||
|
||||
interface HistoryChartRefs {
|
||||
getCanvas: () => HTMLCanvasElement | undefined;
|
||||
getContainer: () => HTMLDivElement | undefined;
|
||||
}
|
||||
|
||||
export function useHistoryChartState(props: HistoryChartProps, refs: HistoryChartRefs) {
|
||||
const [range, setRange] = createSignal<HistoryTimeRange>(props.range || '24h');
|
||||
const [data, setData] = createSignal(props.data ?? []);
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [source, setSource] = createSignal<'store' | 'memory' | 'live' | null>(null);
|
||||
const [maxPoints, setMaxPoints] = createSignal<number | null>(null);
|
||||
const [refreshTick, setRefreshTick] = createSignal(0);
|
||||
const [hasLoadedOnce, setHasLoadedOnce] = createSignal(false);
|
||||
const [cursorX, setCursorX] = createSignal<number | null>(null);
|
||||
const [startingTrial, setStartingTrial] = createSignal(false);
|
||||
const [hoveredPoint, setHoveredPoint] = createSignal<HistoryChartHoverPoint | null>(null);
|
||||
|
||||
const canStartTrial = createMemo(() => {
|
||||
const state = licenseStatus()?.subscription_state;
|
||||
if (!state) return false;
|
||||
return state !== 'active' && state !== 'trial';
|
||||
});
|
||||
|
||||
const handleStartTrial = async () => {
|
||||
if (startingTrial()) return;
|
||||
setStartingTrial(true);
|
||||
try {
|
||||
const result = await startProTrial();
|
||||
if (result?.outcome === 'redirect') {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = result.actionUrl;
|
||||
}
|
||||
return;
|
||||
}
|
||||
notificationStore.success(getProTrialStartedMessage());
|
||||
} catch (err) {
|
||||
const statusCode = (err as { status?: number } | null)?.status;
|
||||
if (statusCode === 409) {
|
||||
notificationStore.error(getTrialAlreadyUsedMessage());
|
||||
} else if (statusCode === 429) {
|
||||
notificationStore.error(getTrialTryAgainLaterMessage());
|
||||
} else {
|
||||
notificationStore.error(
|
||||
getTrialStartErrorMessage(err instanceof Error ? err.message : undefined, {
|
||||
branded: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setStartingTrial(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshIntervalMs = createMemo(() => getHistoryChartRefreshIntervalMs(range()));
|
||||
|
||||
onMount(() => {
|
||||
loadLicenseStatus();
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (props.range) {
|
||||
setRange(props.range);
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (props.data) {
|
||||
setData(props.data);
|
||||
if (!hasLoadedOnce()) setHasLoadedOnce(true);
|
||||
setSource('live');
|
||||
}
|
||||
});
|
||||
|
||||
const updateRange = (nextRange: HistoryTimeRange) => {
|
||||
setRange(nextRange);
|
||||
props.onRangeChange?.(nextRange);
|
||||
};
|
||||
|
||||
const isLocked = createMemo(() => isRangeLocked(range()));
|
||||
const lockDays = createMemo(() => (range() === '30d' ? '30' : '90'));
|
||||
const lockTierLabel = createMemo(() => {
|
||||
const max = maxHistoryDays();
|
||||
const targetDays = range() === '30d' ? 30 : range() === '90d' ? 90 : 14;
|
||||
if (max <= 7 && targetDays <= 14) return 'Relay';
|
||||
return 'Pro';
|
||||
});
|
||||
|
||||
createEffect((wasVisible) => {
|
||||
const visible = isLocked() && !props.hideLock;
|
||||
if (visible && !wasVisible) {
|
||||
trackPaywallViewed('long_term_metrics', 'history_chart');
|
||||
}
|
||||
return visible;
|
||||
}, false);
|
||||
|
||||
const dataMin = createMemo(() => getHistoryChartDataMin(data()));
|
||||
const dataMax = createMemo(() => getHistoryChartDataMax(data()));
|
||||
|
||||
const loadData = async (
|
||||
chartRange: HistoryTimeRange,
|
||||
pointsCap: number | null,
|
||||
isBackgroundRefresh: boolean,
|
||||
) => {
|
||||
if (!isBackgroundRefresh && !hasLoadedOnce()) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
if (!isBackgroundRefresh) {
|
||||
setSource(null);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ChartsAPI.getMetricsHistory({
|
||||
resourceType: props.resourceType,
|
||||
resourceId: props.resourceId,
|
||||
metric: props.metric,
|
||||
range: chartRange,
|
||||
maxPoints: pointsCap ?? undefined,
|
||||
});
|
||||
|
||||
if ('points' in result) {
|
||||
setData(result.points || []);
|
||||
setSource(result.source ?? 'store');
|
||||
} else {
|
||||
setData([]);
|
||||
setSource(result.source ?? 'store');
|
||||
}
|
||||
if (!hasLoadedOnce()) {
|
||||
setHasLoadedOnce(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch metrics history:', err);
|
||||
if (!hasLoadedOnce()) {
|
||||
setError('Failed to load history data');
|
||||
}
|
||||
setSource(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(async () => {
|
||||
if (props.data) return;
|
||||
if (!props.resourceId || !props.resourceType) return;
|
||||
|
||||
const chartRange = range();
|
||||
const locked = isLocked();
|
||||
const pointsCap = maxPoints();
|
||||
|
||||
if (locked) {
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setSource(null);
|
||||
return;
|
||||
}
|
||||
|
||||
void loadData(chartRange, pointsCap, false);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const tick = refreshTick();
|
||||
if (tick === 0) return;
|
||||
if (!props.resourceId || !props.resourceType || isLocked()) return;
|
||||
|
||||
void loadData(range(), maxPoints(), true);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const interval = refreshIntervalMs();
|
||||
if (!interval || interval <= 0) return;
|
||||
const timer = window.setInterval(() => {
|
||||
setRefreshTick((value) => value + 1);
|
||||
}, interval);
|
||||
onCleanup(() => window.clearInterval(timer));
|
||||
});
|
||||
|
||||
const drawChart = () => {
|
||||
const canvas = refs.getCanvas();
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const points = data();
|
||||
const width = canvas.parentElement?.clientWidth || 300;
|
||||
const height = props.height || 200;
|
||||
|
||||
setupCanvasDPR(canvas, ctx, width, height);
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
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';
|
||||
const axisTextColor = isDark ? '#9ca3af' : '#6b7280';
|
||||
const mainColor = getHistoryChartDefaultColor(props.metric, props.color);
|
||||
const scale = getHistoryChartScale(points, props.unit);
|
||||
|
||||
ctx.strokeStyle = gridColor;
|
||||
ctx.lineWidth = 1;
|
||||
for (const tick of getHistoryChartYAxisLabels(scale)) {
|
||||
const y = height - 20 - tick.pct * (height - 40);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(40, y);
|
||||
ctx.lineTo(width, y);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(tick.label, 35, y);
|
||||
}
|
||||
|
||||
if (points.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const geometry = createHistoryChartGeometry({
|
||||
width,
|
||||
height,
|
||||
startTime: points[0].timestamp,
|
||||
endTime: points[points.length - 1].timestamp,
|
||||
minValue: scale.minValue,
|
||||
maxValue: scale.maxValue,
|
||||
});
|
||||
|
||||
ctx.beginPath();
|
||||
points.forEach((point, index) => {
|
||||
if (index === 0) ctx.moveTo(geometry.getX(point.timestamp), height - 20);
|
||||
ctx.lineTo(geometry.getX(point.timestamp), geometry.getY(point.value));
|
||||
});
|
||||
if (points.length > 0) {
|
||||
ctx.lineTo(geometry.getX(points[points.length - 1].timestamp), height - 20);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = `${mainColor}66`;
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = mainColor;
|
||||
ctx.lineWidth = 2;
|
||||
points.forEach((point, index) => {
|
||||
if (index === 0) ctx.moveTo(geometry.getX(point.timestamp), geometry.getY(point.value));
|
||||
else ctx.lineTo(geometry.getX(point.timestamp), geometry.getY(point.value));
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = axisTextColor;
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'bottom';
|
||||
|
||||
const labelCount = 4;
|
||||
for (let index = 0; index < labelCount; index++) {
|
||||
const timestamp =
|
||||
points[0].timestamp + (geometry.timeSpan * index) / (labelCount - 1);
|
||||
const x = geometry.getX(timestamp);
|
||||
ctx.fillText(formatHistoryChartTimeLabel(timestamp, range()), x, height - 2);
|
||||
}
|
||||
|
||||
const cursor = cursorX();
|
||||
if (cursor === null || cursor < 40 || points.length === 0) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = isDark ? 'rgba(255, 255, 255, 0.4)' : 'rgba(0, 0, 0, 0.3)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cursor, 0);
|
||||
ctx.lineTo(cursor, height - 20);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
const ratio = (cursor - 40) / (width - 40);
|
||||
const hoverTimestamp = points[0].timestamp + ratio * geometry.timeSpan;
|
||||
const closest = findHistoryChartClosestPoint(points, hoverTimestamp);
|
||||
const pointX = geometry.getX(closest.timestamp);
|
||||
const pointY = geometry.getY(closest.value);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(pointX, pointY, 5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = isDark ? '#1f2937' : '#ffffff';
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(pointX, pointY, 4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = mainColor;
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(pointX, pointY, 2, 0, Math.PI * 2);
|
||||
ctx.fillStyle = isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(255, 255, 255, 0.8)';
|
||||
ctx.fill();
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
cursorX();
|
||||
drawChart();
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const container = refs.getContainer();
|
||||
if (!container) return;
|
||||
|
||||
const updateMaxPoints = () => {
|
||||
const width = container.clientWidth || 0;
|
||||
if (width <= 0) return;
|
||||
const next = calculateOptimalPoints(width, 'history');
|
||||
if (next !== maxPoints()) {
|
||||
setMaxPoints(next);
|
||||
}
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updateMaxPoints();
|
||||
drawChart();
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
updateMaxPoints();
|
||||
onCleanup(() => resizeObserver.disconnect());
|
||||
});
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
const canvas = refs.getCanvas();
|
||||
const points = data();
|
||||
if (!canvas || points.length === 0) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const width = rect.width;
|
||||
const geometry = createHistoryChartGeometry({
|
||||
width,
|
||||
height: props.height || 200,
|
||||
startTime: points[0].timestamp,
|
||||
endTime: points[points.length - 1].timestamp,
|
||||
minValue: getHistoryChartScale(points, props.unit).minValue,
|
||||
maxValue: getHistoryChartScale(points, props.unit).maxValue,
|
||||
});
|
||||
|
||||
if (x < 40) {
|
||||
setCursorX(null);
|
||||
setHoveredPoint(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setCursorX(x);
|
||||
const ratio = (x - 40) / (width - 40);
|
||||
const hoverTimestamp = points[0].timestamp + ratio * geometry.timeSpan;
|
||||
const closest = findHistoryChartClosestPoint(points, hoverTimestamp);
|
||||
|
||||
setHoveredPoint({
|
||||
value: closest.value,
|
||||
timestamp: closest.timestamp,
|
||||
x: rect.left + x,
|
||||
y: rect.top + 20,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoveredPoint(null);
|
||||
setCursorX(null);
|
||||
};
|
||||
|
||||
return {
|
||||
canStartTrial,
|
||||
data,
|
||||
dataMax,
|
||||
dataMin,
|
||||
error,
|
||||
getUpgradeActionUrlOrFallback,
|
||||
handleMouseLeave,
|
||||
handleMouseMove,
|
||||
handleStartTrial,
|
||||
hoveredPoint,
|
||||
isLocked,
|
||||
loading,
|
||||
lockDays,
|
||||
lockTierLabel,
|
||||
range,
|
||||
ranges: ['24h', '7d', '30d', '90d'] as HistoryTimeRange[],
|
||||
source,
|
||||
startingTrial,
|
||||
trackUpgradeClicked,
|
||||
updateRange,
|
||||
};
|
||||
}
|
||||
|
||||
export type HistoryChartState = ReturnType<typeof useHistoryChartState>;
|
||||
@@ -8,11 +8,14 @@ import chartsApiSource from '@/api/charts.ts?raw';
|
||||
import investigateAlertButtonSource from '@/components/Alerts/InvestigateAlertButton.tsx?raw';
|
||||
import alertTargetTypesSource from '@/utils/alertTargetTypes.ts?raw';
|
||||
import resourceBadgesSource from '@/components/Infrastructure/resourceBadges.ts?raw';
|
||||
import historyChartSource from '@/components/shared/HistoryChart.tsx?raw';
|
||||
import historyChartModelSource from '@/components/shared/historyChartModel.ts?raw';
|
||||
import infrastructureSummaryTableSource from '@/components/shared/InfrastructureSummaryTable.tsx?raw';
|
||||
import infrastructureSummaryTableRowSource from '@/components/shared/InfrastructureSummaryTableRow.tsx?raw';
|
||||
import interactiveSparklineSource from '@/components/shared/InteractiveSparkline.tsx?raw';
|
||||
import interactiveSparklineModelSource from '@/components/shared/interactiveSparklineModel.ts?raw';
|
||||
import sharedInfrastructureSummaryTableModelSource from '@/components/shared/infrastructureSummaryTableModel.ts?raw';
|
||||
import historyChartStateSource from '@/components/shared/useHistoryChartState.ts?raw';
|
||||
import interactiveSparklineStateSource from '@/components/shared/useInteractiveSparklineState.ts?raw';
|
||||
import infrastructureSummaryTableStateSource from '@/components/shared/useInfrastructureSummaryTableState.ts?raw';
|
||||
import resourceBadgePresentationSource from '@/utils/resourceBadgePresentation.ts?raw';
|
||||
@@ -2634,6 +2637,15 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(interactiveSparklineModelSource).toContain(
|
||||
'computeInteractiveSparklineHoverState',
|
||||
);
|
||||
expect(historyChartSource).toContain('useHistoryChartState');
|
||||
expect(historyChartSource).not.toContain('ChartsAPI.getMetricsHistory');
|
||||
expect(historyChartSource).not.toContain('calculateOptimalPoints');
|
||||
expect(historyChartSource).not.toContain('setupCanvasDPR');
|
||||
expect(historyChartStateSource).toContain('ChartsAPI.getMetricsHistory');
|
||||
expect(historyChartStateSource).toContain('calculateOptimalPoints');
|
||||
expect(historyChartStateSource).toContain('setupCanvasDPR');
|
||||
expect(historyChartModelSource).toContain('formatHistoryChartTooltipValue');
|
||||
expect(historyChartModelSource).toContain('getHistoryChartScale');
|
||||
expect(useUnifiedResourcesSource).not.toContain('normalizeResourcePolicyAISafeSummary(');
|
||||
expect(useUnifiedResourcesSource).not.toContain('normalizeResourcePolicy(');
|
||||
expect(useUnifiedResourcesSource).not.toContain('const resolvePolicySensitivity =');
|
||||
|
||||
Reference in New Issue
Block a user