Split history chart runtime owners

This commit is contained in:
rcourtman
2026-03-23 01:26:10 +00:00
parent 6801690ec2
commit d1b64812d7
8 changed files with 758 additions and 620 deletions
@@ -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 =');