diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 524f7a74a..fcbc4ae7c 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index fe61af827..13d48e715 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -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", diff --git a/frontend-modern/src/components/shared/HistoryChart.tsx b/frontend-modern/src/components/shared/HistoryChart.tsx index de0657387..c886a8080 100644 --- a/frontend-modern/src/components/shared/HistoryChart.tsx +++ b/frontend-modern/src/components/shared/HistoryChart.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 = (props) => { let canvasRef: HTMLCanvasElement | undefined; let containerRef: HTMLDivElement | undefined; - const [range, setRange] = createSignal(props.range || '24h'); - const [data, setData] = createSignal([]); - const [loading, setLoading] = createSignal(false); - const [error, setError] = createSignal(null); - const [source, setSource] = createSignal<'store' | 'memory' | 'live' | null>(null); - const [maxPoints, setMaxPoints] = createSignal(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(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 (
= (props) => { ({props.unit}) - + - {source() === 'live' ? 'Live' : 'Buffer'} + {chart.source() === 'live' ? 'Live' : 'Buffer'}
- +
Min - {formatTooltipValue(dataMin()!, props.unit)} + + {formatHistoryChartTooltipValue(chart.dataMin()!, props.unit)} + Max - {formatTooltipValue(dataMax()!, props.unit)} + + {formatHistoryChartTooltipValue(chart.dataMax()!, props.unit)} +
- {/* Time Range Selector */}
- {ranges.map((r) => ( + {chart.ranges.map((range) => ( ))}
@@ -662,12 +85,11 @@ export const HistoryChart: Component = (props) => { - {/* Empty State */} - +
@@ -695,22 +117,19 @@ export const HistoryChart: Component = (props) => {
- {/* Loading State */} - +
- {/* Error State */} - +
-

{error()}

+

{chart.error()}

- {/* Pro Lock Overlay */} - +
= (props) => {
-

{lockDays()}-Day History

+

{chart.lockDays()}-Day History

- Upgrade to {lockTierLabel()} to unlock {lockDays()} days of historical data retention. + Upgrade to {chart.lockTierLabel()} to unlock {chart.lockDays()} days of historical + data retention.

- + {(point) => (
= (props) => { {new Date(point().timestamp).toLocaleString()}
- {formatTooltipValue(point().value, props.unit)} + {formatHistoryChartTooltipValue(point().value, props.unit)}
)} diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 5a8ab3e72..df07a272d 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -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'); + }); }); diff --git a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx new file mode 100644 index 000000000..bdf10732b --- /dev/null +++ b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx @@ -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(() => ( + + )); + + expect(screen.getByText('History')).toBeInTheDocument(); + }); +}); diff --git a/frontend-modern/src/components/shared/historyChartModel.ts b/frontend-modern/src/components/shared/historyChartModel.ts new file mode 100644 index 000000000..c877826fe --- /dev/null +++ b/frontend-modern/src/components/shared/historyChartModel.ts @@ -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; +} diff --git a/frontend-modern/src/components/shared/useHistoryChartState.ts b/frontend-modern/src/components/shared/useHistoryChartState.ts new file mode 100644 index 000000000..79eddeb68 --- /dev/null +++ b/frontend-modern/src/components/shared/useHistoryChartState.ts @@ -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(props.range || '24h'); + const [data, setData] = createSignal(props.data ?? []); + const [loading, setLoading] = createSignal(false); + const [error, setError] = createSignal(null); + const [source, setSource] = createSignal<'store' | 'memory' | 'live' | null>(null); + const [maxPoints, setMaxPoints] = createSignal(null); + const [refreshTick, setRefreshTick] = createSignal(0); + const [hasLoadedOnce, setHasLoadedOnce] = createSignal(false); + const [cursorX, setCursorX] = createSignal(null); + const [startingTrial, setStartingTrial] = createSignal(false); + const [hoveredPoint, setHoveredPoint] = createSignal(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; diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 559a3f8fb..7e982a384 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -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 =');